BlogCI/CD

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.

Jun 24, 2026·4 min readIntermediate·By the SecOpsLog team · command-tested

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.

Multi-stage build path from source to deployable image

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.

1Build stageFROM golang/node AS build2Deps layerCOPY lockfiles, install3CompileRUN go build / npm run build4Runtime stageFROM distroless/scratch5COPY --fromartifact only6USER nonrootdrop root in final stage7Push + scansmaller SBOM surface

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.

bash — measure the differencelive
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 app
app single 1.02GB
app multi 184MB — toolchain never left the build stage

Build 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.

Dockerfile.multi
# --- build stage ---
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
# --- runtime stage ---
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/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.

What ends up in the final image
Single stage
Full SDK (~450MB for Go)
apt/apk caches
Source + .git in context
Shell + package manager
Every build-time secret layer
Multi stage
Static binary or dist/ only
No compiler, no shell
No source tree
Runs as nonroot
Scan surface drops sharply
COPY --from is not a security boundary by itself
BuildKit can pass secrets with RUN --mount=type=secret, but a careless COPY --from=build / /app still dumps the entire build tree into production. Copy explicit paths. Audit the final image with docker history and a scanner — not your memory of the Dockerfile.

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.

Go deeper in a courseAdvanced container securityMinimal images, non-root runtime, and the escape paths they close.View course

Related posts