CoursesAdvanced container securityMinimal & secure images

Multi-stage builds, in depth

Leave every compiler and package manager behind.

Advanced14 min · lesson 5 of 25

Every tool in your build stage is attack surface in production you never wanted. A compiler can build a payload; a package manager can fetch one; a shell can run one; leftover source and credentials can leak. The multi-stage build is the mechanism that severs the build environment from the runtime environment: do all the messy work in a builder stage, then copy only the finished artifact into a clean, minimal final stage that ships nothing else.

MULTI-STAGE: SEVER BUILD FROM RUNTIME
1Builder stage
golang:1.23 — compiler, git, shell (~800MB)
2Compile the artifact
CGO_ENABLED=0 go build -o /out/server
3COPY --from=build
copy only the binary, never the whole stage
4Minimal final stage
distroless/static:nonroot (~2MB, no shell)
The builder and its toolchain are discarded, never shipped. Use BuildKit secret mounts so build creds never land in a layer.
Dockerfile
FROM golang:1.23 AS build # fat: compiler, git, shell, ~800MB
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static:nonroot # ~2MB: no shell, no pkg mgr, non-root
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]

Copy the artifact, never the stage

The discipline that makes it work is copying the narrowest possible thing with COPY --from — one binary, one directory of assets — never COPY --from=build /, which drags the whole toolchain back and defeats the point. Name stages with AS and you can also build a debug or test target from the same file with --target, so one Dockerfile yields a tiny production image and a fuller debugging image that share an identical build.

terminal
$ docker build -t svc:1.0 .
$ docker images svc
REPOSITORY TAG SIZE
svc 1.0 6.4MB # the 800MB builder never shipped
$ docker build --target build -t svc:debug . # a full-toolchain image when you need one

Build-time secrets stay out of layers

Discarded builder stages never reach the registry, but do not rely on that for secrets — a private key or token COPY-ed into a builder still lives in that stage’s layers during the build and can leak through cache. Use BuildKit’s secret mounts, which expose a credential to a single RUN without ever writing it to a layer.

Dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci # the token is present for this RUN only, never in any layer
# build with: docker build --secret id=npmrc,src=$HOME/.npmrc .
Multi-stage is the foundation, not the finish
A multi-stage build gets you a small image, but the base of the final stage decides how small and how safe — a multi-stage build onto ubuntu:latest still ships a shell and apt. The next lessons pick that final base: distroless, scratch, and static binaries are what turn “smaller” into “nothing to attack.”