CoursesDocker in depthImage management

Multi-stage builds

Small images with no build tools left behind.

Intermediate12 min · lesson 2 of 30

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.

MULTI-STAGE BUILD: TOOLCHAIN IN, ARTIFACT OUT
1Builder stage
FROM golang:1.23 — full compiler + source, ~800MB
2Compile
go build -o /out/server
3COPY --from=build
copy only the finished binary
4Final image
distroless + binary, ~20MB; builder discarded
Only the last stage ships; every build tool left in the builder is weight and attack surface removed.
Dockerfile
FROM golang:1.23 AS build # stage 0: the full toolchain
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static:nonroot # stage 1: nothing but the binary
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/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.

terminal
$ docker build -t payments-api:1.0 .
$ docker images payments-api
REPOSITORY TAG SIZE
payments-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.

terminal
# 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.

Dockerfile
FROM golang:1.23 AS build
COPY . .
RUN go build -o /out/api ./cmd/api && go build -o /out/worker ./cmd/worker
FROM gcr.io/distroless/static:nonroot AS api
COPY --from=build /out/api /api
ENTRYPOINT ["/api"]
FROM gcr.io/distroless/static:nonroot AS worker
COPY --from=build /out/worker /worker
ENTRYPOINT ["/worker"]
# docker build --target api ... ; docker build --target worker ...
Copy only what you need — and keep build secrets out of layers
A lazy COPY --from=build / drags the whole toolchain back into the final image and defeats the point; copy the specific artifact. And even though builder stages are discarded, do not COPY secrets into them as plaintext — use BuildKit’s RUN --mount=type=secret so the credential is available during the build without ever landing in a layer.