Your Docker images are probably too big. Most applications ship with compilers, package managers, and debug tools that never run in production. Multi-stage builds fix this by separating your build environment from your runtime environment.

The Problem with Single-Stage Builds

Here’s a typical Node.js Dockerfile:

1
2
3
4
5
6
7
8
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

This works, but the resulting image includes:

  • The full Node.js development environment
  • npm and all its dependencies
  • Your node_modules with dev dependencies
  • TypeScript compiler (if you’re building TS)
  • Source files you don’t need at runtime

Result: A 1.2GB image for a 50MB application.

Multi-Stage to the Rescue

Multi-stage builds let you use multiple FROM statements. Each FROM starts a new build stage, and you can selectively copy artifacts between stages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 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-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]

The node:20-slim image is 200MB vs 1.1GB for the full image. But we can go further.

Going Minimal with Distroless

Google’s distroless images contain only your application and its runtime dependencies. No shell, no package manager, no attack surface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Production-only dependencies
FROM node:20 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 3: Distroless runtime
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
CMD ["dist/index.js"]

This three-stage approach:

  1. Builds your TypeScript/JavaScript
  2. Creates a clean production node_modules
  3. Copies only what’s needed into a minimal runtime

Result: ~150MB image with no shell access for attackers.

Go Applications: The Ultimate Slim

Go’s static compilation makes for even smaller images:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .

# Stage 2: Scratch (empty) image
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]

The scratch image is literally empty — 0 bytes. Your final image is just your binary plus SSL certificates. A typical Go API server comes in under 15MB.

The -ldflags="-s -w" strips debug symbols, saving another 30% on binary size.

Python with Virtual Environments

Python multi-stage builds are trickier because of dynamic linking, but still worthwhile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Stage 1: Build
FROM python:3.12-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Stage 2: Runtime
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
CMD ["python", "app.py"]

The virtual environment isolates your dependencies, and copying the whole /opt/venv directory preserves all installed packages.

Build Arguments for Flexibility

Make your multi-stage builds configurable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
ARG NODE_ENV=production
ARG BUILD_TARGET=dist

FROM node:20 AS builder
ARG NODE_ENV
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim AS production
WORKDIR /app
COPY --from=builder /app/${BUILD_TARGET} ./${BUILD_TARGET}
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

FROM node:20 AS development
WORKDIR /app
COPY --from=builder /app .
CMD ["npm", "run", "dev"]

Build for different targets:

1
2
3
4
5
# Production
docker build --target production -t myapp:prod .

# Development (with hot reload)
docker build --target development -t myapp:dev .

Caching Strategies

Layer ordering matters for cache efficiency. Put rarely-changing layers first:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM node:20 AS builder
WORKDIR /app

# These rarely change - cached
COPY package*.json ./
RUN npm ci

# These change often - rebuild from here
COPY . .
RUN npm run build

For monorepos, use .dockerignore aggressively:

n.td.og.eoedimscnetdts_smodules

Security Benefits

Multi-stage builds improve security by:

  1. Reducing attack surface — No compiler means no compiling malware
  2. Removing secrets — Build-time secrets don’t persist to runtime
  3. Eliminating tools — No curl, wget, or shell in distroless images

For secrets during build, use BuildKit:

1
2
3
4
# syntax=docker/dockerfile:1
FROM node:20 AS builder
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

Build with:

1
docker build --secret id=npm_token,src=.npm_token .

The secret is available during build but never persists in any layer.

Measuring the Impact

Before and after on a real Node.js API:

ApproachImage SizeBuild Time
Single-stage node:201.24 GB45s
Multi-stage node:20-slim287 MB48s
Multi-stage distroless152 MB51s

The 3-6 second build overhead pays for itself in:

  • Faster image pulls during deployment
  • Reduced storage costs
  • Smaller attack surface
  • Faster container startup

Start Small

You don’t need to go full distroless on day one. Start by:

  1. Switching to -slim or -alpine base images
  2. Separating build and runtime stages
  3. Using .dockerignore to exclude junk

Then iterate toward smaller, more secure images as your CI/CD matures.

Your containers should contain your application, not your entire development environment.