Docker Multi-Stage Builds: Shrink Images by 80% for Production
A Node.js API that ships a 1.2 GB image in development has no business being that size in production. The node_modules, build tools, compilers, and dev dependencies that you need to build the app are completely unnecessary to run it. Multi-stage builds solve this problem at the Dockerfile level — no external tooling, no CI tricks, just cleaner layering.
This article walks through real multi-stage patterns for Go, Node.js, and Python, plus the mistakes most teams make when implementing them.
What Multi-Stage Builds Actually Do
A traditional Dockerfile builds in one stage. Every tool you install, every cache layer from npm install, every compiler binary — it all ends up in the final image. Multi-stage builds let you define multiple FROM stages in a single Dockerfile. Only the final stage ships.
# Stage 1 — builder (large, disposable)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 — runtime (small, ships to production)
FROM node:20-alpine AS runtime
WORKDIR /app
COPY /app/dist ./dist
COPY /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/server.js"]
The builder stage installs everything. The runtime stage copies only what it needs. Docker discards the builder layer entirely from the final image.
Real-World Size Comparison
Here's what multi-stage builds deliver across common stacks:
| Stack | Single-stage | Multi-stage | Reduction |
|---|---|---|---|
| Node.js (Express API) | 1.1 GB | 210 MB | 81% |
| Go binary | 890 MB | 18 MB | 98% |
| Python (FastAPI) | 1.4 GB | 320 MB | 77% |
| Java (Spring Boot) | 780 MB | 195 MB | 75% |
The Go numbers are dramatic because the final Go binary is statically compiled — it needs nothing from the build environment at runtime.
Pattern 1: Go — The Scratch Image
Go compiles to a single static binary. Your runtime image can be scratch (literally empty) or gcr.io/distroless/static.
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Download deps first (better layer caching)
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/bin/api ./cmd/api
# Final stage — nothing but the binary
FROM gcr.io/distroless/static:nonroot AS runtime
COPY /app/bin/api /api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]
Key flags:
CGO_ENABLED=0— disables C bindings, ensures truly static binary-ldflags="-w -s"— strips debug info and symbol table (cuts ~30% off binary size)distroless/static:nonroot— no shell, no package manager, runs as non-root by default
# Build and check size
docker build -t myapp:latest .
docker image ls myapp:latest
# REPOSITORY TAG IMAGE ID CREATED SIZE
# myapp latest a3f9c1234abc 5 seconds ago 14.2MB
Pattern 2: Node.js — Production Dependencies Only
The most common Node.js mistake is copying all of node_modules into the runtime image. You want node_modules without devDependencies.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Copy prod deps from deps stage, built artifacts from builder
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
COPY /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Three stages here: deps installs only production dependencies, builder does the full build, runtime assembles the final image from both.
If you're using Next.js, leverage the standalone output:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Next.js standalone mode bundles only what the server needs — no full node_modules required.
Pattern 3: Python — Wheels for Deterministic Deps
Python is trickier because it doesn't compile to a single binary. The standard approach: build wheels in a builder stage, install them in a clean runtime stage.
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build tools
RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
FROM python:3.12-slim AS runtime
WORKDIR /app
# Copy pre-built wheels from builder, install them (no compiler needed)
COPY /app/wheels /wheels
COPY /app/requirements.txt .
RUN pip install --no-cache /wheels/*
COPY . .
USER nobody
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
The builder stage compiles native extensions (like cryptography or psycopg2). The runtime stage installs pre-built wheels without needing gcc at all.
Layer Cache Optimization
Multi-stage builds amplify the value of good layer ordering. Always copy dependency manifests before source code:
# WRONG — source change busts the npm ci cache
COPY . .
RUN npm ci
# RIGHT — npm ci cache only busts when package.json changes
COPY package*.json ./
RUN npm ci
COPY . .
This matters in CI where build times compound across dozens of daily deployments.
Testing Your Build
Check image contents without running the container:
# Inspect layers and sizes
docker history myapp:latest
# Confirm no unexpected binaries (e.g., no bash in distroless)
docker run --rm myapp:latest sh
# → OCI runtime exec failed (expected — no shell)
# Check what's actually in the image
docker run --rm --entrypoint ls myapp:latest /
Use dive for a visual layer breakdown:
brew install dive # or apt install dive
dive myapp:latest
Common Mistakes
Copying the wrong files. COPY --from=builder /app . copies everything, including test files and temp build artifacts. Be explicit: COPY --from=builder /app/dist ./dist.
Not pinning base image digests. node:20-alpine can change under you. In production, pin to a digest:
FROM node:20-alpine@sha256:a1b2c3d4... AS builder
Forgetting .dockerignore. Without it, COPY . . sends your entire working directory to the Docker daemon — including node_modules, .git, and .env files. Always have a .dockerignore:
node_modules
.git
.env*
*.log
dist
coverage
Running as root in the final stage. Add USER node, USER nobody, or a numeric UID. Most base images include a non-root user — use it.
BuildKit for Parallel Stages
Docker BuildKit (enabled by default since Docker 23) runs independent stages in parallel, which can significantly cut build times for complex multi-stage setups.
# Ensure BuildKit is active
DOCKER_BUILDKIT=1 docker build -t myapp:latest .
# Or set in daemon config
echo '{"features": {"buildkit": true}}' > /etc/docker/daemon.json
With BuildKit, independent stages like deps and builder run simultaneously rather than sequentially.
Putting It Together
Multi-stage builds are not a micro-optimization — they are table stakes for any team shipping Docker images to production. An 80% image size reduction means faster pulls, lower registry storage costs, smaller attack surfaces, and faster container starts during scale-out events.
Start with your heaviest image. Add a builder stage, move your build tools there, and copy only the artifacts into a lean runtime stage. Measure before and after. The numbers will speak for themselves.
Was this article helpful?
SRE & Observability Engineer
If it's not measured, it doesn't exist. SLO-driven, metrics-obsessed, and the person who gets paged at 3 AM so you don't have to. Observability isn't optional.
Related Articles
Docker "Permission Denied" On Mounted Volumes: Fix Unix Socket And File Ownership Errors
If you've spent more than five minutes with Docker volumes, you've seen it. That infuriating wall of red text: Or worse, the cryptic Unix socket variant: Y...
Reducing Docker Image Build Times With BuildKit Cache Mounts
If your Docker builds are slow, cache mounts are probably the single highest-leverage fix available. I've seen Node.js image builds drop from 4 minutes to...
Fix Docker Container DNS Resolution Failures
Resolve DNS lookup failures inside Docker containers — fix resolv.conf, custom networks, and upstream DNS configuration issues.
Fix Docker 'No Space Left on Device' Error
Reclaim disk space when Docker fills your drive — clean up dangling images, stopped containers, unused volumes, and build cache.
Docker CLI Cheat Sheet
Essential Docker CLI commands organized by task — build images, run containers, manage volumes and networks, compose services, and debug.
Docker Multi-Stage Builds for Production-Ready Minimal Images
Shrink Docker images from 1.2GB to 45MB using multi-stage builds. Production Dockerfiles for Node.js, Go, and Python with real size comparisons.