Docker Multi‑Stage Build Architecture: Requirements, Minimal Design, and Operational Checks
Learn how to split build‑time and runtime concerns in a Dockerfile to produce smaller, safer images and when to revisit the design.
07 Jul 2026, 22:52 UTC

Problem: Large, insecure, non‑reproducible container images
When a single Dockerfile compiles code and ships the resulting binary together with compilers, package managers, and intermediate layers, the final image often exceeds hundreds of megabytes, contains unnecessary build tools, and makes it hard to guarantee that the same source produces identical images across CI pipelines.
Requirements
- Produce a runtime image that contains only the artifacts needed to run the application.
- Separate build‑time dependencies (compilers, libraries, caches) from the runtime filesystem to reduce attack surface.
- Enable reproducible builds: unchanged source should yield byte‑identical layers when the build cache is used.
- Keep the Dockerfile simple enough to be maintained by a small team while still supporting multi‑arch or rootless builds when needed.
Smallest Suitable Design
The minimal pattern that satisfies the requirements is a Dockerfile with exactly two stages:
- Builder stage – starts from a full SDK or build image (e.g.,
golang:1.22,maven:3.9-eclipse-temurin-17) and performs all compilation, testing, and packaging steps. - Runtime stage – starts from a minimal base (e.g.,
gcr.io/distroless/static,alpine:3.20) and copies only the compiled output from the builder stage usingCOPY --from=builder.
No additional stages are required unless you need to handle secrets, multi‑arch manifests, or rootless execution.
Trust and Data Boundaries
The builder stage has full access to the source tree, package managers, and any build‑time secrets you choose to expose. The runtime stage receives only the files explicitly copied via COPY --from=builder. This creates a clear trust boundary:
- Build tools, intermediate object files, and cache layers never appear in the final image.
- If a secret is inadvertently copied into the builder stage, it remains confined to that stage unless you also copy it into the runtime stage.
Operational Checks
After building the image, run the following checks to confirm the design goals are met:
- Image size:
docker image ls <image-name>– compare to a single‑stage build; the multi‑stage image should be markedly smaller (often <10 MiB vs >200 MiB for compiled languages). - Absence of build tools:
docker run --rm <image-name> which gcc || echo "no gcc"– the command should fail or print “no gcc”. - Application start‑up:
docker run --rm <image-name> <entrypoint>– the binary or script should launch without error. - Cache reuse: rebuild with unchanged source and observe that Docker reports “Using cache” for the builder stage layers; only the runtime stage should be rebuilt if the copied artifact changed.
Example Dockerfile (Go)
# Builder stage
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum .
RUN go mod download
COPY . .
RUN go build -o myapp ./cmd/main
# Runtime stage
FROM gcr.io/distroless/static
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
This file separates the Go toolchain (builder) from the final distroless image (runtime). Adjust the base images and copy paths for other languages.
Verification Steps (to be performed by the user)
- Save the Dockerfile above as
Dockerfilein a directory containing a simple Go program. - Build:
docker build -t myapp:test . - Check size:
docker image ls myapp:test - Confirm no gcc:
docker run --rm myapp:test sh -c 'which gcc || echo "no gcc"' - Run the binary:
docker run --rm myapp:test– you should see the program’s output.
These steps are illustrative; you must adapt them to your language and base images.
Failure Modes
- Builder stage failure – any compilation error stops the build; fix the source or dependencies.
- Incorrect COPY paths – missing files in the runtime image cause startup errors; verify paths with
docker run --rm <image> ls -l /before relying on the image. - Base image updates – a new distroless or alpine tag may introduce CVEs or break compatibility; monitor upstream advisories and rebuild.
- Cache poisoning – if you change the builder stage but forget to invalidate the cache (e.g., by altering a
COPYthat Docker cannot detect), you may get stale artifacts. Use build arguments or change a version flag to force a rebuild.
Conditions That Would Change the Design
- Multi‑arch images are required and you need to use
docker buildxwith platform‑specific builder stages; you may add a manifest‑creation stage. - Rootless execution is mandated in your environment; ensure the runtime user has appropriate permissions on copied files, possibly adding a
USERinstruction in the runtime stage. - You must inject build‑time secrets (e.g., private package tokens) without leaving traces; then consider Docker build secrets (
--secret) or a separate secrets stage that is not copied. - The application requires a dynamic linker or glibc not present in distroless; you may need to switch the runtime base to a slim variant (e.g.,
debian:slim) while still keeping the builder separate.
Limitations and Practical Checks
Multi‑stage builds rely on Docker Engine ≥ 17.05; older versions will ignore multiple FROM lines and produce an error. Verify your engine version with docker version --format '{{.ServerVersion}}' before adopting the pattern.
Even with a clean runtime stage, the builder stage may still contain sensitive data in its layers. To confirm no secrets leaked, inspect the builder image (if you keep it) with docker run --rm <builder-image> strings <path> | grep -i <pattern> or simply avoid copying secrets into any stage.
Finally, periodically re‑evaluate the design when your build pipeline adds new steps (e.g., code generation, asset minification) that might benefit from an additional dedicated stage, or when base image updates shift the trade‑off between size and compatibility.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.