LearnKubernetes
Kubernetes1. Containerization Basics·EXPERT·4 min read

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 too
  • scratch base image (0 bytes) ideal for statically-compiled binaries; CGO_ENABLED=0 prevents 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=0 during Go builds, or use -static flag 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, not COPY --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.

🔒 Locked
Sign in and unlock the full syllabus to keep reading.
Sign in to continue
Knowledge check
5 scenario questions on this topic
Take the quiz →
Related in 1. Containerization Basics
Docker Fundamentals
INTERMEDIATE
Dockerizing Applications
INTERMEDIATE