AI-assisted: the checklist and the example are mine, and an AI tool helped tighten the wording. Every behaviour below is checked against Docker's own documentation on multi-stage builds and build secrets. I run LearnSome.tech, where we teach Docker; you don't need it for anything here.
A multi-stage build is a Dockerfile with more than one FROM. Each FROM starts a new stage from a clean slate, and COPY --from=<stage> carries only the files you name into the next one. The compiler, the test tools and the source stay behind.
Here is the smallest useful shape, a Go program shipped on an empty base:
# syntax=docker/dockerfile:1
FROM golang:1.24 AS build
WORKDIR /src
COPY . .
# a static binary, so it runs on the empty scratch image
RUN CGO_ENABLED=0 go build -o /bin/hello .
FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]
When I review someone else's multi-stage Dockerfile, these are the six questions I ask.
1. Are the stages named?
FROM golang:1.24 AS build and COPY --from=build keep working when someone reorders the file later. A numbered stage (--from=0) points at whatever stage is first, so adding a stage above it quietly changes what gets copied.
2. What does the last stage actually contain?
Only the last stage ships by default. Check that it starts from a small base and copies in the built output and nothing else: no COPY . . in the final stage. A smaller image means fewer packages, which means fewer vulnerabilities to patch.
3. Can I build one stage on its own?
docker build --target build -t app:build . stops at the stage called build, which is handy for running tests inside it or debugging a failing step. With BuildKit, only the stages the target depends on are built; the legacy builder runs every stage before the target.
4. How does a secret reach the build?
Build arguments and environment variables persist in the final image, so they are the wrong way to hand a build a token. Use a secret mount, which exists only for the one RUN that asks for it:
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci
docker build --secret id=npmtoken,src=./npmtoken .
Copying a secret in and deleting it in a later step doesn't work either: the earlier layer still holds it.
5. Is every ARG declared in the stage that uses it?
An ARG declared in one stage isn't visible in an unrelated stage. If two stages need VERSION, both need the ARG VERSION line. This one causes a lot of "why is my variable empty?" questions.
6. Is the base pinned when it needs to be, and is there a .dockerignore?
Image tags are mutable, so pin the base to a digest when you need the exact same bytes every time. And a .dockerignore that leaves out .git, local environments and .env files keeps the build context small and stops a broad COPY from picking up something private.
If you only take one thing from this: open your final stage and list every file in it. Anything you can't explain shouldn't be there.
What's the biggest image you've shrunk this way?
Top comments (1)
The numbered-stage trap is a good first item, it's the kind of thing that breaks silently months later. One more I'd add for a scratch final stage: copy CA certificates in if the binary makes TLS calls, otherwise the first HTTPS request fails at runtime with an error that doesn't mention certificates at all.