Multi-stage builds

Docker · Practical · 12 min

It's the single most effective way to make production images small and secure.

Building software needs compilers, dev headers, test tools and package caches; running it needs none of that. A multi-stage build puts several FROM instructions in one Dockerfile. Each starts a new stage, and you copy just the artifacts you want from earlier stages with COPY --from=<stage>. Only the last stage becomes the image (unless you pick another), so build tools never reach production.

Typical shapes:

  • Compiled languages (Go, Rust): build in golang:1.25, copy the single binary into gcr.io/distroless/static or even scratch. The image drops from about 1 GB to about 10 MB.
  • Frontend apps: npm run build in a Node stage, copy the dist/ folder into an nginx stage.
  • Interpreted apps: install dependencies with build tools present (to compile native wheels), then copy the installed packages or virtualenv into a slim runtime stage.

Name stages with AS, as in FROM golang:1.25 AS build, and reference them by name.

Other useful tricks:

  • docker build --target test . stops at a given stage, so one Dockerfile can define dev, test and prod images.
  • COPY --from=nginx:1.27 /etc/nginx/nginx.conf . copies from any image, not just stages.
  • BuildKit builds independent stages in parallel and skips stages the target doesn't depend on.

Make sure the runtime stage really has what the program needs at run time: CA certificates, timezone data, the C library if the binary is dynamically linked (static Go binaries need CGO_ENABLED=0).

Example

# syntax=docker/dockerfile:1
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM build AS test
RUN go test ./...

FROM gcr.io/distroless/static-debian12 AS prod
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]

# docker build -t api:prod .              -> last stage (prod), ~10 MB
# docker build --target test -t api:test .

Remember

  • Only the final (or --target) stage becomes the image
  • COPY --from=stage copies artifacts between stages
  • --target lets one Dockerfile produce dev, test and prod images
  • Check the runtime stage has certs, libc and data files the app needs

Watch out for

  • Shipping a dynamically linked binary into scratch or distroless/static, where it fails with a confusing 'no such file or directory'.

Check yourself

  1. In a multi-stage Dockerfile without --target, which stage becomes the image?
  2. What does COPY --from=build /out/app /app do?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.docker.com/build/building/multi-stage/