Multi-stage Docker builds that cut image size by 80%
Split build tooling from the runtime so your final image ships only the binary and its deps — smaller, faster, safer.
A single-stage Dockerfile is a suitcase you never unpacked: the Go compiler, npm cache, apt lists, and half your repo ride along to production even though the running process only needs one binary. Multi-stage builds fix that by compiling in a fat stage and copying a named artifact into a thin runtime stage. Everything else — toolchains, headers, build secrets in env vars — stays behind when the stage ends.
You will compare single vs multi-stage image sizes, write a two-stage Dockerfile for a static Go binary, and land on distroless as the runtime base. If Dockerfiles are still new, start with image layers in Containers & Docker fundamentals — multi-stage is the first serious optimization most teams skip.
Only paths you COPY --from= survive. The build stage can mount secrets and run compilers; the runtime stage should not even have a package manager.
Why one stage ships too much
Attackers do not care that your production container could compile code — they care that bash, curl, and a stale libssl are present when they land an RCE. A 1 GB image also slows pulls, bloats registry bills, and gives vulnerability scanners more packages to argue about. Multi-stage is not micro-optimization; it is removing tools that should never have been on the attack surface.
docker build -t app:single -f Dockerfile.single .docker build -t app:multi -f Dockerfile.multi .docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" | grep appapp single 1.02GBapp multi 184MB — toolchain never left the build stageBuild fat, run thin
Name the build stage (AS build) so later instructions can reference it. Download dependencies before copying all source — that layer stays cached when only application code changes. The runtime stage gets the binary and nothing else.
# --- build stage ---FROM golang:1.22 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app# --- runtime stage ---FROM gcr.io/distroless/static:nonrootCOPY --from=build /out/app /appUSER nonroot:nonrootENTRYPOINT ["/app"]
Patterns beyond Go
Node and Java benefit the same way: a node:20 stage runs npm ci && npm run build, then COPY --from=build /app/dist into nginx:alpine or a static file server. For interpreted stacks you copy built assets, not node_modules from the build stage unless you truly need them at runtime — often a production npm ci --omit=dev in the final stage is cleaner.
You can also use multi-stage to test inside the build without shipping test binaries: a stage runs go test ./... and fails the build, but only the final stage becomes an image layer consumers pull. That keeps CI honest while preserving the slim runtime artifact teams actually deploy.
Where this goes next
Multi-stage gets you a small artifact; distroless or hardened bases remove the shell an attacker expects. Add .dockerignore, pin bases by digest, and wire image scanning into CI so size wins do not hide fresh CVEs. The runtime hardening track in Advanced container security covers seccomp, capabilities, and the escapes minimal images help stop.