A typical Node.js development image can be 1GB+. A well-crafted production image? Under 50MB. Multi-stage builds are the key to creating minimal, secure Docker images without sacrificing developer experience.

The Problem with Single-Stage Builds

# ❌ Single-stage build - includes everything
FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

CMD ["node", "dist/index.js"]

This image contains:

  • Full Node.js runtime with npm
  • All development dependencies (TypeScript, ESLint, test frameworks)
  • Source files we don’t need
  • Build cache and artifacts

Result: ~1.2GB image with a large attack surface.

Multi-Stage Build Fundamentals

Multi-stage builds use multiple FROM statements. Each stage starts fresh, and you can copy artifacts between stages:

# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

Better, but we can do much better.

Optimized Node.js Multi-Stage Build

# ═══════════════════════════════════════════════════════════
# Stage 1: Dependencies
# ═══════════════════════════════════════════════════════════
FROM node:20-alpine AS deps

WORKDIR /app

# Install dependencies for native modules
RUN apk add --no-cache libc6-compat

# Copy only package files first (better layer caching)
COPY package.json package-lock.json ./

# Install ALL dependencies (including devDependencies for build)
RUN npm ci

# ═══════════════════════════════════════════════════════════
# Stage 2: Builder
# ═══════════════════════════════════════════════════════════
FROM node:20-alpine AS builder

WORKDIR /app

# Copy dependencies from previous stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Build the application
RUN npm run build

# Remove devDependencies for production
RUN npm prune --production

# ═══════════════════════════════════════════════════════════
# Stage 3: Production
# ═══════════════════════════════════════════════════════════
FROM node:20-alpine AS production

# Security: run as non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 appuser

WORKDIR /app

# Set production environment
ENV NODE_ENV=production
ENV PORT=3000

# Copy only what we need
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/package.json ./

# Switch to non-root user
USER appuser

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# Start application
CMD ["node", "dist/index.js"]

Result: ~150MB with only production dependencies and proper security.

Ultra-Minimal with Distroless

For even smaller images, use Google’s distroless base:

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production

# Production stage - distroless
FROM gcr.io/distroless/nodejs20-debian12

WORKDIR /app

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

ENV NODE_ENV=production

CMD ["dist/index.js"]

Result: ~130MB with no shell, no package manager — minimal attack surface.

Go Application: From 1GB to 10MB

Go’s static compilation makes it perfect for minimal images:

# ═══════════════════════════════════════════════════════════
# Stage 1: Build
# ═══════════════════════════════════════════════════════════
FROM golang:1.22-alpine AS builder

# Install certificates for HTTPS
RUN apk add --no-cache ca-certificates git

WORKDIR /app

# Download dependencies first (better caching)
COPY go.mod go.sum ./
RUN go mod download

# Copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags='-w -s -extldflags "-static"' \
    -o /app/server ./cmd/server

# ═══════════════════════════════════════════════════════════
# Stage 2: Production (scratch)
# ═══════════════════════════════════════════════════════════
FROM scratch

# Copy certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy binary
COPY --from=builder /app/server /server

# Non-root user (numeric for scratch)
USER 65534

EXPOSE 8080

ENTRYPOINT ["/server"]

Result: ~10MB — literally just your binary and CA certificates!

Python Application

Python requires more care since it’s interpreted:

# ═══════════════════════════════════════════════════════════
# Stage 1: Build dependencies
# ═══════════════════════════════════════════════════════════
FROM python:3.12-slim AS builder

WORKDIR /app

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# ═══════════════════════════════════════════════════════════
# Stage 2: Production
# ═══════════════════════════════════════════════════════════
FROM python:3.12-slim AS production

# Security: non-root user
RUN useradd --create-home --shell /bin/bash appuser

WORKDIR /app

# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Copy application code
COPY --chown=appuser:appuser . .

USER appuser

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]

Build Target Selection

Use targets to build specific stages:

# Base stage with common setup
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./

# Development stage
FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

# Production dependencies
FROM base AS prod-deps
RUN npm ci --only=production

# Build stage
FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

Build specific targets:

# Development
docker build --target development -t myapp:dev .

# Production
docker build --target production -t myapp:prod .

Layer Caching Best Practices

Order your Dockerfile from least to most frequently changing:

FROM node:20-alpine

WORKDIR /app

# 1. System dependencies (rarely change)
RUN apk add --no-cache libc6-compat

# 2. Package files (change when adding dependencies)
COPY package.json package-lock.json ./

# 3. Install dependencies (cached unless package files change)
RUN npm ci

# 4. Application code (changes frequently)
COPY . .

# 5. Build (runs on every code change)
RUN npm run build

Security Hardening

Non-Root User

# Create user in build stage
FROM node:20-alpine AS builder
# ... build steps ...

FROM node:20-alpine AS production
# Create user with specific UID/GID
RUN addgroup -g 1001 -S nodejs && \
    adduser -S -u 1001 -G nodejs appuser

WORKDIR /app
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
USER appuser

Read-Only Filesystem

# In docker-compose.yml or docker run
security_opt:
  - no-new-privileges:true
read_only: true
tmpfs:
  - /tmp

Scan for Vulnerabilities

# Build and scan
docker build -t myapp:latest .
docker scout cves myapp:latest

# Or use Trivy
trivy image myapp:latest

.dockerignore

Always include a .dockerignore to exclude unnecessary files:

# .dockerignore
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.git
.gitignore
.dockerignore
README.md
LICENSE
.env*
*.md
test/
tests/
coverage/
.nyc_output/
.github/
docs/
*.log
.DS_Store

Image Size Comparison

Stage StrategyNode.js AppGo App
Single stage (node:20)~1.2 GB~1.1 GB
Single stage (alpine)~200 MB~300 MB
Multi-stage (alpine)~150 MB~50 MB
Multi-stage (distroless)~130 MB~15 MB
Multi-stage (scratch)N/A~10 MB

Key Takeaways

  1. Separate build and runtime — don’t ship compilers
  2. Use alpine or distroless for minimal base images
  3. Order layers by change frequency for better caching
  4. Run as non-root — always
  5. Use .dockerignore to exclude unnecessary files
  6. Scan images for vulnerabilities before deploying
  7. Use build targets for dev/prod variants

“The best container is the smallest container that still works. Every byte you don’t ship is a byte that can’t be exploited.”