DevOpsil
Docker
94%
Needs Review

Docker Multi-Stage Builds: Shrink Images by 80% for Production

Riku TanakaRiku Tanaka6 min read

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 --from=builder /app/dist ./dist
COPY --from=builder /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:

StackSingle-stageMulti-stageReduction
Node.js (Express API)1.1 GB210 MB81%
Go binary890 MB18 MB98%
Python (FastAPI)1.4 GB320 MB77%
Java (Spring Boot)780 MB195 MB75%

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 --from=builder /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 --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /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 --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /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 --from=builder /app/wheels /wheels
COPY --from=builder /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.

Share:

Was this article helpful?

Riku Tanaka
Riku Tanaka

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

DockerQuick RefBeginnerNeeds Review

Docker CLI Cheat Sheet

Essential Docker CLI commands organized by task — build images, run containers, manage volumes and networks, compose services, and debug.

Sarah Chen·
3 min read

Discussion