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"]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/python3That’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"]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--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 /helloNo 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 passedOr 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"]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 $PATHThat’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
- Docker interview questions
- Docker MCQ practice
- Docker distroless and minimal images
- Docker layers and build cache invalidation
- Official reference: Multi-stage builds