Multi-Stage Builds
Ship the Binary, Not the Workshop
Building software often needs a whole toolbox: compilers, SDKs, dev-only dependencies. Running the finished software needs almost none of that. If you use one Dockerfile for both, the toolbox ships to production right along with your app - bigger images, slower deploys, and a much larger attack surface for anyone who breaks in. A multi-stage build solves this by letting one Dockerfile define more than one FROM stage: an early stage does the heavy building, and only the finished output gets copied into a small, clean final stage - the build tools never make it into what actually ships.
# ── Stage 1: the workshop (1.1 GB with Go toolchain) ──
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /out/api ./cmd/api
# ── Stage 2: what actually ships (~15 MB) ──
FROM alpine:3.20
COPY --from=build /out/api /usr/local/bin/api
USER nobody
CMD ["api"]
Results: 1.1 GB → 15 MB. Smaller pulls, faster deploys, faster autoscaling - and a fraction of the attack surface (no compiler, no shell tools for an attacker to use).
| Base for the final stage | Size | When |
|---|---|---|
ubuntu | ~78 MB | need apt + familiar tooling |
alpine | ~7 MB | most services (musl libc caveats) |
gcr.io/distroless/* | ~2-20 MB | max security: no shell at all |
scratch | 0 B | static binaries only |
Tip: Multi-stage is also how you keep secrets out: run npm ci with a private-registry token in the build stage - only the built artifacts, not the token, reach the final image.