The Image Size Problem
A naive Dockerfile for a Node.js app might produce a 1.2 GB image — because it includes the compiler toolchain, test dependencies, and intermediate build artifacts that are only needed during compilation, not at runtime. Multi-stage builds solve this by using multiple FROM instructions in a single Dockerfile, letting you copy only the final artifacts into a minimal production image.
The result: a Go binary image can shrink from 800 MB to 12 MB. A Node.js API from 1.2 GB to 120 MB. A Python app from 900 MB to 180 MB.
Basic Multi-Stage Pattern
Each FROM starts a new stage. Use COPY --from=stagename to pull artifacts from previous stages:
# Stage 1: Build
FROM node:20-alpine 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
ENV NODE_ENV=production
# Only copy what's needed
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
EXPOSE 3000
CMD ["node", "dist/server.js"]
The builder stage is discarded after the build. The final image contains only the compiled output and production dependencies.
Go — The Extreme Case
Go compiles to a static binary, making it perfect for scratch or distroless images:
# 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="-w -s" -o server ./cmd/server
# Stage 2: Minimal runtime (no OS at all)
FROM scratch AS production
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
FROM scratch is a completely empty image. Add ca-certificates for HTTPS calls. Final image: ~8-15 MB.
Python — Using Distroless
# Stage 1: Build dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2: Minimal production image
FROM python:3.12-slim AS production
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Run as non-root
RUN adduser --disabled-password --gecos "" appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Layer Caching Optimization
Docker caches each layer. If a layer's content changes, all subsequent layers are invalidated. Order your instructions from least-to-most frequently changed:
FROM node:20-alpine AS builder
WORKDIR /app
# 1. Copy dependency files FIRST (changes rarely)
COPY package*.json ./
RUN npm ci # Cached until package.json changes
# 2. Copy source code LAST (changes frequently)
COPY . .
RUN npm run build # Only re-runs when source changes
Never do COPY . . before installing dependencies — that invalidates the npm install cache on every code change.
BuildKit Cache Mounts
Docker BuildKit (enabled by default in Docker 23+) supports cache mounts that persist across builds:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Cache npm's download cache between builds
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN --mount=type=cache,target=/app/.next/cache \
npm run build
Build with: DOCKER_BUILDKIT=1 docker build . or use Docker Desktop which enables BuildKit automatically.
Multi-Stage for Testing
Include a test stage that only runs in CI — production builds skip it entirely:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Test stage (not in production image)
FROM builder AS test
RUN npm run test
RUN npm run lint
# Production (copies from builder, not test)
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
CMD ["node", "dist/server.js"]
# CI: build and run tests
docker build --target test -t myapp:test .
# Production: build final image (skips test stage)
docker build --target production -t myapp:prod .
Size Comparison
| Approach | Node.js App | Go App |
|---|---|---|
| Single stage (node:20) | 1.2 GB | 800 MB |
| Multi-stage (alpine) | 120 MB | 12 MB |
| Multi-stage (scratch) | N/A | 8 MB |
Frequently Asked Questions
Can I reference a stage by index instead of name?
Yes — COPY --from=0 copies from the first stage. But always use named stages (AS builder) for readability and to avoid breaking if you insert a new stage.
Does multi-stage build increase build time?
Initial builds take the same time — you're doing the same work. Subsequent builds are often faster because layer caching is more effective with well-structured stages.
Can I use multi-stage with docker-compose?
Yes. In docker-compose.yml, use target: production under the build key to specify which stage to build. Use DevKits Dockerfile Generator Pro to scaffold optimized multi-stage Dockerfiles automatically.