Skip to content

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 .git right back in. Copy exact paths.
  • CGO is a trap. CGO_ENABLED=0 produces a static binary that runs on scratch; the moment you need CGO (go-sqlite3 is the classic), you need glibc — use debian:bookworm-slim or distroless/base, not static.
  • No shell means no shell. docker exec into 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 ARG or ENV in the build stage does not leak into the final stage. Re-declare what you need — deliberately.