Multi-stage builds
Small images with no build tools left behind.
Build tooling is weight and attack surface at once: compilers, package managers, headers, and source the running app never touches. A multi-stage build keeps all of it in one or more builder stages and copies only the finished artifact into a clean final stage, so the shipped image is small and carries almost nothing an intruder could use. Each FROM starts a new stage; only the last stage becomes the image unless you say otherwise.
FROM golang:1.23 AS build # stage 0: the full toolchainWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /out/server ./cmd/serverFROM gcr.io/distroless/static:nonroot # stage 1: nothing but the binaryCOPY --from=build /out/server /serverUSER nonrootENTRYPOINT ["/server"]
The numbers make the case: the golang builder is ~800 MB with a compiler and shell; the distroless result is ~20 MB with a single static binary. Every megabyte removed is a dependency you no longer patch and a tool an attacker no longer finds — and a small image pulls faster onto every node, which compounds across a fleet.
$ docker build -t payments-api:1.0 .$ docker images payments-apiREPOSITORY TAG SIZEpayments-api 1.0 21MB # the final stage only; the 800MB builder is discarded
Scenario: reuse one build for test and production
You want CI to run the test suite against the exact build the image was made from, without shipping the test tools. Name the stages and add a dedicated test stage; --target builds up to a chosen stage and stops. CI builds the test target and runs it; the release pipeline builds the default (final) target. One Dockerfile, two purpose-built outputs, guaranteed to share the same build.
# Dockerfile has: FROM build AS test / RUN go test ./...$ docker build --target test -t payments-api:test . # stops after tests$ docker build -t payments-api:1.0 . # full build, tiny final image# CI: build --target test (fails the pipeline if tests fail), then build release
Scenario: a shared builder feeding two images
A monorepo builds both an API and a worker from the same compiled tree. Multi-stage lets one builder stage compile everything, and two final stages each copy just their binary — so the expensive compile happens once and you get two minimal images. COPY --from can also pull from an external image (COPY --from=nginx:1.27 …), handy for grabbing a single file without installing a package.
FROM golang:1.23 AS buildCOPY . .RUN go build -o /out/api ./cmd/api && go build -o /out/worker ./cmd/workerFROM gcr.io/distroless/static:nonroot AS apiCOPY --from=build /out/api /apiENTRYPOINT ["/api"]FROM gcr.io/distroless/static:nonroot AS workerCOPY --from=build /out/worker /workerENTRYPOINT ["/worker"]# docker build --target api ... ; docker build --target worker ...