Ch. 13 · Docker

Docker Multi-Stage Builds and Runtime Contents

Compile in one stage, ship only the output in the next: how a 400 MB build image became a 313 kB runtime, and what a scratch image gives up.

~5 min readbeginnerupdated Oct 7, 2026

A Dockerfile that compiles code needs compilers, headers, package managers and dev dependencies. The container that runs the result needs none of them. Before multi-stage builds, teams kept two Dockerfiles and a shell script to shuttle artifacts between them. Now one Dockerfile can hold both: a build stage with everything, a runtime stage with almost nothing, and an explicit copy between them.

The measured example here compiles a small C program, because the node:22-bookworm base image happens to include gcc and that makes the size difference easy to see without downloading anything. The same structure applies to Go, Rust, Java, .NET and front-end builds.

The single-stage baseline

FROM node:22-bookworm
WORKDIR /src
COPY hello.c .
RUN gcc -O2 -static -o /usr/local/bin/hello hello.c
CMD ["hello"]
dockerfile

It works, and it ships the entire build environment:

$ docker run --rm single
hello from a static binary
$ docker run --rm single sh -c 'which gcc git curl python3'
/usr/bin/gcc /usr/bin/git /usr/bin/curl /usr/bin/python3
Text

That’s 400 MB of compressed content to pull onto every server, hundreds of packages for a vulnerability scanner to flag, and a compiler, git and curl sitting ready for anyone who gets code execution in the container. None of it is used at runtime.

Splitting build and runtime

FROM node:22-bookworm AS build
WORKDIR /src
COPY hello.c .
RUN mkdir -p /out && gcc -O2 -static -o /out/hello hello.c

FROM alpine:3.20 AS test
COPY --from=build /out/hello /usr/local/bin/hello
RUN hello | grep -q "static binary" && echo "smoke test passed"

FROM scratch AS runtime
COPY --from=build /out/hello /hello
USER 65532:65532
ENTRYPOINT ["/hello"]
dockerfile

Each FROM begins a fresh stage with its own filesystem. AS build names it so later stages can refer to it. COPY --from=build copies one path out of that stage’s result into the current one. The final stage starts from scratch, an empty image, so the runtime contains exactly one file:

$ docker image ls
IMAGE      ID             DISK USAGE   CONTENT SIZE
single     b889cf18d989       1.62GB          400MB
ms         3e2538f887c9       1.02MB          313kB
$ docker history ms --format '{{.CreatedBy}}\t{{.Size}}'
ENTRYPOINT ["/hello"]	0B
USER 65532:65532	0B
COPY /out/hello /hello # buildkit	709kB
Text

--from also accepts an image reference instead of a stage name. COPY --from=nginx:alpine /etc/nginx/mime.types /etc/ pulls a single file out of another image, which is handy for grabbing a statically linked tool or a config file without installing a package. Pin those references by tag or digest like any other base image, because they change the contents of your build just as much.

The build stage’s layers and history aren’t part of the result at all. That also means a build arg or a downloaded file in the build stage doesn’t appear in the final image’s history, though as the secrets note explains, it does stay in the builder’s cache.

The stage that never ran

Look at what BuildKit actually built for the default target:

#6 [build 2/4] WORKDIR /src
#7 [build 3/4] COPY hello.c .
#8 [build 4/4] RUN mkdir -p /out && gcc -O2 -static -o /out/hello hello.c
#9 [runtime 1/1] COPY --from=build /out/hello /hello
Text

No test stage. BuildKit works backwards from the target and builds only the stages it depends on. Nothing copies from test, so it was skipped, and the smoke test never ran. That’s a feature (unused stages cost nothing) and a trap: teams add a test stage, see green builds, and don’t notice the tests aren’t executing. The legacy builder ran every stage in order, so this behaviour changed under people’s feet when BuildKit became the default.

Run the stage explicitly in CI with --target:

$ docker build --target test -t ms-test .
#11 [test 2/3] COPY --from=build /out/hello /usr/local/bin/hello
#12 [test 3/3] RUN hello | grep -q "static binary" && echo "smoke test passed"
#12 0.257 smoke test passed
Text

Or make the runtime stage depend on it, for example by copying a marker file from test, so a failing test fails the production build.

--target is useful in other ways too: a dev stage with hot-reload tooling for local Compose, a debug stage that adds a shell, all in the same file and sharing cached layers with the production path.

A realistic Node.js layout

For an interpreted language the win is smaller but still real: dev dependencies, TypeScript and build tooling stay behind. An illustrative pattern:

FROM node:22-bookworm AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .
USER node
CMD ["node", "dist/server.js"]
dockerfile

Some decisions here deserve a sentence each in an interview. FROM deps AS build reuses the cached install layer, so source edits don’t reinstall packages (see layers and cache). The runtime uses the -slim variant of the same Node major version and Debian release, so native modules compiled in the build stage find the same glibc. Mixing a Debian builder with an Alpine runtime is the classic mistake: binaries linked against glibc fail on musl with a misleading not found error for a file that exists. npm prune --omit=dev strips dev packages before the copy. And only dist, node_modules and package.json cross over, so tests, source maps you didn’t mean to ship and the .git directory don’t.

What you give up

A scratch or distroless runtime has no shell, so docker exec -it app sh fails:

exec: "sh": executable file not found in $PATH
Text

That’s the point, and it’s also an operational cost. Plan for it: use a debug target or the distroless :debug tags in non-production, attach a debugging sidecar (docker run --pid=container:app --network=container:app with a tools image), and make sure logs and metrics tell you enough that you rarely need a shell. scratch also lacks CA certificates, time zone data and /etc/passwd, so programs making HTTPS calls need certificates copied in from the build stage. The distroless note compares the minimal base options.

Interview practice

A Go service’s Dockerfile is a single stage on golang:1.23 and produces a 900 MB image. You switch to a multi-stage build that copies the binary into scratch. The image is now 15 MB, but the service fails at runtime with x509: certificate signed by unknown authority when calling a payment API, and logs show times in UTC even though TZ=Europe/Paris is set. Explain and fix both.

Answer

scratch is empty: no CA bundle and no time zone database. Go’s TLS client looks for system root certificates in standard paths such as /etc/ssl/certs/ca-certificates.crt, finds none, and can’t verify the payment provider’s certificate. Go’s time package reads zone data from /usr/share/zoneinfo; with nothing there, it can’t load Europe/Paris and falls back to UTC.

Copy both from the build stage, which has them: COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ and COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo. Alternatively, embed zone data in the binary with the time/tzdata package, or use a distroless static base, which includes certificates, tzdata and a nonroot user. Also make sure the binary is statically linked (CGO_ENABLED=0) since scratch has no libc. Keep the certificate bundle up to date by rebuilding regularly, because it’s now part of your image.

Keep going

More in Docker

read ✓Docker · easy

Docker Entrypoint and Command Overrides

How ENTRYPOINT and CMD combine into one argv, what docker run arguments replace, and why appending sh to some images never gives you a shell.

~6 min readread →
read ✓Docker · easy

Docker Build Context and Ignore Rules

What the build context is, why COPY . . can ship your .env and .git, and how to write .dockerignore patterns that match nested folders.

~5 min readread →
read ✓Docker · easy

Docker Layers and Build Cache Invalidation

Why one edited source file can rerun npm install: how BuildKit's layer cache keys each step, and how to order a Dockerfile so rebuilds stay fast.

~5 min readread →
esc