Multi-stage Docker Builds
Multi-stage Docker Builds
TL;DR
- Multi-stage builds isolate build-time dependencies from runtime images, reducing size and attack surface
- First stage compiles artifacts; final stage copies only compiled output to lightweight base
COPY --from=<stage>moves artifacts between stages; can reference external images tooscratchbase image (0 bytes) ideal for statically-compiled binaries;CGO_ENABLED=0prevents glibc crashes- Build-time tools (compilers, SDKs) add 800MB+ but binary itself is ~15MB—strip the bloat
- Layer caching in build stage accelerates CI/CD by reusing dependency downloads
Core Concepts
Build Bloat Problem
Modern applications (TypeScript, Go, Java, Rust) need compilers, SDKs, and build tools. These tools—Go compiler, JDK, Rustc, Maven, Gradle, npm—are not needed at runtime. Leaving them in production images increases size dramatically (e.g., Go SDK ~800MB vs. binary ~15MB) and expands security surface; attackers can compile exploits on-the-fly if shell and compilers exist.
Multi-Stage Architecture
Multi-stage Dockerfiles contain multiple FROM blocks. Heavy build stages compile artifacts; lightweight runtime stages receive only the final compiled assets via COPY --from=<stage>. No compiler, no source code, no bloat.
BUILD STAGE (golang:1.20-alpine)
├─ Install Go SDK
├─ Copy source + dependencies
└─ Compile binary → /app
RUNTIME STAGE (scratch or alpine)
├─ COPY --from=builder /app /app
├─ No SDK, compilers, or source
└─ Final image: ~15MB
The scratch Base Image
scratch is an empty image (0 bytes)—no OS files, shell, libraries, or package managers. Ideal for statically-compiled binaries (Go, Rust, C) that bundle all system calls. Minimizes size and reduces CVE surface to zero.
Cross-Image Artifact Copying
COPY --from=<image> can reference any published image, not just build stages in the current Dockerfile. Example: COPY --from=consul:latest /bin/consul /usr/local/bin/consul pulls Consul, extracts binary, discards rest.
Comparison Table
| Aspect | Build Stage | Runtime Stage |
|---|---|---|
| Base Image | Heavy (golang, node, jdk) | Lightweight (scratch, alpine, nginx) |
| Contains | SDK, compilers, source code | Only compiled binaries/assets |
| Size | 800MB+ | 5–50MB |
| Security Surface | High (shell, package managers) | Minimal |
| Purpose | Compilation only | Execution only |
Command & Configuration Reference
Build a Specific Stage Only
docker build --target builder -t my-app:test .
Target Production Build (Final Stage)
docker build -t my-app:prod .
Verify Image Sizes
docker images | grep my-app
# Shows size reduction: builder 800MB → final 18MB
Go Multi-Stage Example
FROM golang:1.20-alpine AS builder
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o main .
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /src/main /app
USER 10001
ENTRYPOINT ["/app"]
Node.js/React Multi-Stage Example
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.23-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Common Pitfalls
Pitfall: Dynamic Linking Crashes with scratch or Alpine
If binary is compiled dynamically (CGO enabled) on Debian-based build image and copied to scratch or alpine runtime, it crashes on startup with confusing “file not found” error. Binary seeks libc.so (glibc), which doesn’t exist in scratch/alpine (uses musl).
- Why: Shared library dependencies are not portable across libc implementations.
- Fix: Force static compilation with
CGO_ENABLED=0during Go builds, or use-staticflag in C/Rust.
Pitfall: Including Source Code in Final Image Accidentally copying entire source tree to final stage instead of just compiled output.
- Why: Wastes space and exposes source code in production.
- Fix: Be explicit with
COPY --from=builder /src/binary /app, notCOPY --from=builder . .
Pitfall: Forgetting Layer Caching Benefits
Placing COPY . . before RUN go mod download causes rebuild on every source change.
- Why: Layer cache invalidates on any file change.
- Fix: Order steps by frequency of change—dependencies first, source code last.
Interview Questions
Q — What is the core advantage of multi-stage Docker builds over single-stage builds? Multi-stage builds isolate compile-time tools from runtime, drastically reducing image size (800MB → 15MB) and attack surface. Final image contains only compiled binaries, no compiler or package manager for attackers to leverage.
Q — Can COPY --from reference images outside the current Dockerfile?
Yes. COPY --from=consul:latest /bin/consul /usr/local/bin/consul pulls any published image from a registry, extracts the file, and discards the rest. Useful for reusing pre-built binaries without maintaining them in your build stage.
Q — Why must Go binaries use CGO_ENABLED=0 when targeting scratch or Alpine?
Go with CGO enabled links dynamically against glibc. Alpine uses musl libc, and scratch has no libc at all. Binary fails on startup seeking missing libc.so. Static compilation bundles all libc symbols into the binary itself, eliminating dependency.