Docker Multi-Stage Builds — Reduce Image Size by 90%

Docker multi-stage builds explained: reduce image size by 90%, separate build and runtime dependencies, optimize layer caching, and build production-ready minimal images.

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.