Your Docker images are probably too big. Mine were. Then I learned about multi-stage builds.

The problem is simple: build tools bloat production images. You need Node.js and npm to build your React app, but you only need nginx to serve it. You need Go and its toolchain to compile, but the binary runs standalone. Every megabyte of build tooling in your production image is wasted space, slower deploys, and expanded attack surface.

Multi-stage builds solve this elegantly.

The Basic Pattern

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 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 nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Two FROM statements. The first stage (builder) has all the build tools. The second stage starts fresh and copies only what’s needed — the built artifacts.

Result: your production image has nginx and your static files. No node_modules. No npm. No source code.

Go Applications

Go shines here because it compiles to a single binary:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Build stage
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 /app/server

# Production stage
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]

FROM scratch means literally nothing — an empty filesystem. Your final image contains only your binary and SSL certificates. A typical Go API server: 10-15MB total.

The -ldflags="-s -w" strips debug symbols, shaving off a few more megabytes.

Python with Virtual Environments

Python’s trickier because you need the interpreter at runtime, but you can still separate build dependencies:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app

RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.in-project true && \
    poetry install --only=main --no-interaction

# Production stage  
FROM python:3.12-slim
WORKDIR /app

COPY --from=builder /app/.venv ./.venv
COPY . .

ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "main.py"]

Build dependencies (poetry, compilers for native extensions) stay in the builder. Production gets only the virtual environment with runtime packages.

Rust: The Extreme Case

Rust takes longer to compile but produces tiny images:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM rust:1.75-alpine AS builder
WORKDIR /app
RUN apk add --no-cache musl-dev
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
ENTRYPOINT ["/myapp"]

A web server in 5MB. No runtime, no interpreter, no libraries. Just your code.

Caching Dependencies

The key optimization: copy dependency manifests first, install, then copy source. Docker caches layers, so unchanged dependencies skip the slow install step:

1
2
3
4
5
6
7
# These layers are cached if package.json hasn't changed
COPY package*.json ./
RUN npm ci

# This layer rebuilds on any source change
COPY . .
RUN npm run build

Order matters. Put the slowest, least-frequently-changing steps first.

Multiple Named Stages

You can have more than two stages, and reference any of them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM deps AS builder
COPY . .
RUN npm run build

FROM deps AS tester
COPY . .
RUN npm test

FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html

Run tests with docker build --target tester . — it stops at that stage. Build production with --target production or just let it run to the end.

Common Gotchas

Copying from the wrong stage:

1
2
3
4
5
# Wrong - copies from the base image, not your builder
COPY /app/dist /usr/share/nginx/html

# Right - explicitly reference the stage
COPY --from=builder /app/dist /usr/share/nginx/html

Missing runtime dependencies: If your app needs shared libraries at runtime (SSL, image processing), you need them in the final image too:

1
2
3
4
5
6
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    libssl3 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/binary /app/binary

File permissions: Files copied between stages keep their permissions. If you need specific ownership:

1
COPY --from=builder --chown=appuser:appgroup /app/binary /app/binary

The Payoff

Before multi-stage builds, my Node.js app image was 1.2GB (node:18 base + node_modules + build tools). After: 45MB (nginx:alpine + static files).

Deploys went from 90 seconds to 8 seconds. Image pulls that used to timeout on slow connections just work now.

Smaller images aren’t just about disk space. They’re faster to push, pull, and start. They have fewer packages to patch. They’re easier to scan for vulnerabilities because there’s less to scan.

Multi-stage builds are the single highest-impact Docker optimization I know. If you’re not using them, start today.


Computing Arts explores the craft of building with code and infrastructure. More at computingarts.com.