Lesson 2 of 9 · 30m
Multi-Stage Builds: Ship the Binary, Not the Toolchain
Split the build from the runtime so the image that ships contains your artifact and nothing else.
Pull a typical "works fine" app image and check its size: 1.4 GB of Go toolchain, package headers, test fixtures, and a .git directory. That bloat isn't cosmetic — it is pull time on every deploy, a bigger attack surface, and a scanner report full of CVEs in packages your app never executes. Multi-stage builds are how you ship the artifact and not the workshop.
The pattern
One Dockerfile, multiple FROM lines. Each FROM starts a fresh stage with a fresh filesystem; earlier stages exist only inside the build. The final image is the last stage's base, plus whatever you explicitly copy across:
# syntax=docker/dockerfile:1
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]The result: a ~20 MB image containing one static binary — no shell, no package manager, no compiler. -trimpath strips local paths from the binary; -ldflags="-s -w" drops symbol tables. COPY --from=build names the stage; numeric --from=0 works too, but names survive reordering.
Choosing the final base
scratch is literally empty: no CA certificates, no /etc/passwd, no /tmp. A Go binary making TLS calls fails on scratch until you copy certs in yourself. The distroless static/base images carry ca-certificates and a nonroot passwd entry, which is why they're the usual choice. For Node you can't go static — the runtime is the dependency — so the idiom is a slim base with only production modules:
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-bookworm-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY dist ./dist
USER node
CMD ["node", "dist/server.js"]Try this
Convert one of your single-stage Dockerfiles to multi-stage. Compare docker images sizes, then docker history the old image and count the toolchain layers you evicted. Bonus: build only the build stage with docker build --target build -t myapp:dev . to get a debug image with the toolchain still inside.
Pitfalls
- Copy the artifact, not the stage.
COPY --from=build /src /drags caches, module trees, and.gitright back in. Copy exact paths. - CGO is a trap.
CGO_ENABLED=0produces a static binary that runs onscratch; the moment you need CGO (go-sqlite3 is the classic), you need glibc — usedebian:bookworm-slimordistroless/base, notstatic. - No shell means no shell.
docker execinto a distroless image fails — that's the point. Debug variants (static-debian12:debug-nonroot) ship busybox when you truly need it. - Stages are independent: an
ARGorENVin the build stage does not leak into the final stage. Re-declare what you need — deliberately.