Chapter 3: Multi-Stage Builds


Multi-stage builds are the single highest-impact optimisation available in Docker. They solve the fundamental tension between “I need build tools to compile my application” and “I don’t want build tools in my production image” — without requiring complex external scripts or two separate Dockerfiles.

The idea is simple: use one or more disposable builder stages to produce an artifact, then copy only that artifact into a minimal runtime stage. The builder stages are discarded at the end of the build and are never pushed to a registry.


The Problem Multi-Stage Builds Solve

Consider a Python application. To install a package with C extensions (like psycopg2), you need gcc, libpq-dev, and other build headers. Once installed, the compiled .so file is all that matters — gcc serves no purpose in the running container.

Without multi-stage builds, you have two bad choices:

  1. Include build tools in the runtime image — simple but produces a large, CVE-heavy image.
  2. Build outside Docker and copy artefacts in — breaks reproducibility and couples your build environment to the host machine.

Multi-stage builds give you a third option: build inside Docker, discard the build environment.


Syntax

A multi-stage Dockerfile uses multiple FROM instructions. Each FROM starts a new stage. Stages can be named with AS <name>:

# Stage 1: builder
FROM python:3.12-slim-bookworm AS builder

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: runtime
FROM python:3.12-slim-bookworm

COPY --from=builder /install /usr/local
COPY . /app
WORKDIR /app
CMD ["python", "main.py"]

The key instruction is COPY --from=builder, which copies files from a named stage rather than from the build context. The builder stage — including any packages installed in it, any cached files, any intermediate build artifacts — is not present in the runtime image.

Full example: code/dockerfiles/python_multistage.Dockerfile


A More Practical Python Pattern: Virtualenv Copy

A cleaner Python pattern isolates dependencies into a virtualenv, then copies the entire virtualenv to the runtime stage:

FROM python:3.12-slim-bookworm AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim-bookworm
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . /app
WORKDIR /app
USER nobody
CMD ["python", "main.py"]

This pattern is self-contained (the venv includes all metadata), easy to audit, and works reliably across all Python package types.


Go: The Ideal Case

Go’s static compilation makes multi-stage builds particularly powerful. The builder stage needs the full Go toolchain; the runtime stage needs only the compiled binary:

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOARCH=amd64 go build -ldflags="-s -w" -o server .

FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

The resulting image is FROM scratch plus a single binary. Typical size: 12–25 MB.

The flags -ldflags="-s -w" strip debug symbols and DWARF information from the binary, typically reducing its size by 20–30%.

Full example: code/dockerfiles/go_scratch.Dockerfile


Targeting a Specific Stage

You can build only up to a specific stage using --target:

docker build --target builder -t myapp:builder .

This is useful for:


Using a Different Base for Each Stage

There is no requirement that stages use the same base image. A common pattern for Node.js is:

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
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/index.js"]

The builder stage installs all dependencies (including dev) and runs the build. The runtime stage installs only production dependencies and copies the compiled output.

Full example: code/dockerfiles/node_multistage.Dockerfile


Multiple Builder Stages

For complex builds, you can chain multiple named stages:

FROM python:3.12-slim AS deps
# install production dependencies

FROM deps AS test
# install test dependencies on top, run tests

FROM python:3.12-slim AS runtime
# copy only from deps, not test
COPY --from=deps /opt/venv /opt/venv

This structure allows CI to build and run the test stage without those test dependencies ever reaching the runtime image.


Key Takeaways


← Chapter 2: Choosing the Right Base Image Table of Contents Chapter 4: Dockerfile Instruction Optimization →