Docker Multi-Stage Builds: Producing Minimal, Secure Container Images
Learn to separate build-time dependencies from runtime artifacts, cut image size 80-95%, and verify no build tools leak into production using multi-stage Dockerfiles.
18 Oct 2026, 16:21 UTC

The Problem: Bloated Images Carry Build‑Time Baggage
A typical Dockerfile pulls a full SDK image, installs compilers, runs tests, and packages the artifact. The resulting image contains those tools and source files, inflating size and expanding the attack surface. For a Go service this can mean 300 MB instead of 15 MB.
Prerequisites
- Docker 23.0 or later (BuildKit enabled by default)
- A project that compiles to a static or self‑contained binary (Go, Rust, Java, Node.js, etc.)
- Basic familiarity with Dockerfile syntax
Core Pattern: Two‑Stage Build
The minimal viable multi‑stage Dockerfile uses two FROM statements. The first stage (builder) contains all build tools. The second stage (runtime) copies only the compiled artifact and any required runtime files.
# Stage 1: Builder
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# Stage 2: Runtime
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
AS buildernames the stage for readableCOPY --from=builderreferences; unnamed stages are referenced by zero‑based index.- CGO_ENABLED=0 produces a fully static binary compatible with
scratchor distroless bases. - The final
FROMstarts fresh—no layers from the builder stage carry over.
Choosing a Runtime Base
| Base Image | Size | Contains | Best For |
|---|---|---|---|
scratch | 0 bytes | Nothing | Fully static binaries (Go, Rust) |
gcr.io/distroless/static-debian12 | ~2 MB | CA certs, timezone data, /etc/passwd | Static binaries needing TLS/time |
alpine:3.20 | ~5 MB | musl libc, apk, busybox | Dynamic binaries, need shell for debugging |
gcr.io/distroless/java17-debian12 | ~120 MB | JRE only, no JDK | Java applications |
If using scratch and the app makes HTTPS calls, copy CA certificates from the builder:
FROM golang:1.23 AS builder
# ... build steps ...
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Advanced: Dependency Caching with BuildKit
BuildKit’s cache mounts prevent re‑downloading dependencies on each build. Place RUN --mount=type=cache before the dependency install step.
# Go example
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
For Node.js with npm:
FROM node:20-alpine AS builder
WORKDIR /src
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
Enable BuildKit explicitly if needed: DOCKER_BUILDKIT=1 docker build … (default in Docker 23+).
Per‑Stage Build Arguments
Declare ARG before each FROM to scope it to that stage. Global ARGs (before the first FROM) apply everywhere.
ARG GO_VERSION=1.23
FROM golang:${GO_VERSION} AS builder
# ...
ARG BASE_IMAGE=gcr.io/distroless/static-debian12
FROM ${BASE_IMAGE}
# ...
Override at build time: docker build --build-arg GO_VERSION=1.22 --build-arg BASE_IMAGE=alpine:3.19 -t myapp .
Monorepo Context Management
In a monorepo, set the build context to the repository root and use relative paths. Each stage copies only what it needs.
# Repository root has .dockerignore excluding .git, node_modules, *.log
# Build from root:
docker build -f services/api/Dockerfile -t api:latest .
Dockerfile at services/api/Dockerfile:
FROM golang:1.23 AS builder
WORKDIR /src
COPY services/api/go.mod services/api/go.sum ./services/api/
RUN cd services/api && go mod download
COPY services/api/ ./services/api/
RUN cd services/api && CGO_ENABLED=0 go build -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Debugging Intermediate Stages
Stop at a specific stage to inspect the build environment:
# Build only the builder stage
docker build --target builder -t myapp:builder .
# Run a shell in the builder image
docker run --rm -it myapp:builder sh
# or for distroless/alpine bases
docker run --rm -it myapp:builder /bin/sh
--target tells BuildKit to stop after that stage and tag the result.
Verification Checklist
- Layer inspection: Run
docker history myapp:latest. Only the final stage’s layers should appear; nogolang,npm, ormavenlayers. - Size comparison:
docker images myapp:latestvs a single‑stage equivalent. Expect 10–50 MB for static Go/Rust binaries on distroless/scratch. - Runtime test:
docker run --rm myapp:latestexecutes successfully with only runtime dependencies. - Vulnerability scan:
docker scout cves myapp:latestortrivy image myapp:latest. Confirm no build‑time packages (compilers, package managers) remain. - Cache effectiveness: Run
docker build -t myapp:latest .twice with no changes. The second build completes in seconds using cached layers. - Context size:
docker build --no-cache -t test . 2>&1 | grep 'sending build context'. Context should be minimal (use.dockerignore).
Limitations and Gotchas
COPY --fromonly references stages defined earlier in the same Dockerfile or external images; it cannot copy from later stages.ARGafter aFROMis scoped to that stage. Re‑declare if needed in subsequent stages.scratchand distroless bases have no shell, noapt/apk, no debugging tools. Addca-certificatesandtzdataviaCOPY --fromif required.- Multi‑stage builds do not automatically scan for vulnerabilities. Run
trivyordocker scouton the final image separately. - Large build contexts slow builds. Always use
.dockerignoreto exclude.git,node_modules,*.log, build outputs, and IDE directories.
Rollback Consideration
Multi‑stage builds produce a new image tag. If verification fails, the previous tag remains untouched—no rollback operation is needed. Simply continue deploying the known‑good tag while debugging the new build.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.