Docker Multi-Stage Builds
Multi-Stage Build
A multi-stage build uses multiple FROM instructions in a single Dockerfile. Each FROM begins a new stage — an isolated build environment. You selectively copy only the artifacts you need from one stage into the next.
Dockerfile with multiple FROM statements
│
▼
┌───────────────────────────────────────────────┐
│ Stage 1 (builder) Stage 2 (tester) │
│ - Full compiler - Run tests │
│ - Build tools - Generate reports │
│ - Dev dependencies ↓ │
│ ↓ artifacts │
│ Stage 3 (production) │
│ - Only runtime │
│ - Only compiled binary/assets │
│ - Tiny, secure image │
└───────────────────────────────────────────────┘
Key directive: COPY --from=<stage_name_or_index> <src> <dest>
2. The Problem Without Multi-Stage Builds
Single Dockerfile (❌ Fat Image)
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install # includes devDependencies
COPY . .
RUN npm run build
# Everything above is baked in — compilers, source, node_modules
CMD ["node", "dist/index.js"]
Two-Dockerfile Approach (❌ Complex Scripts)
# build.sh — fragile shell glue
docker build -f Dockerfile.build -t myapp-build .
docker create --name extract myapp-build
docker cp extract:/app/dist ./dist
docker rm -f extract
docker build -f Dockerfile.prod -t myapp:prod .
Problems with both approaches:
| Issue | Single Dockerfile | Two Dockerfiles |
|---|---|---|
| Image size | Huge (GBs) | Small ✓ |
| Complexity | Low ✓ | High |
| Build toolchain leaked | Yes | No ✓ |
| Source code in image | Yes | No ✓ |
| CI/CD friendly | Somewhat | Hard |
| Single file | Yes ✓ | No |
Solution: Multi-Stage Builds (✅ Best of Both)
3. Core Architecture & Flow
High-Level Flow
flowchart TD
A[docker build .] --> B{Parse Dockerfile}
B --> C[Stage 1: Builder]
C --> C1[Install compilers & tools]
C1 --> C2[Copy source code]
C2 --> C3[Compile / Build artifacts]
C3 --> D[Stage 2: Tester]
D --> D1[Copy build artifacts]
D1 --> D2[Run unit tests]
D2 --> D3{Tests pass?}
D3 -- No --> E[❌ Build fails]
D3 -- Yes --> F[Stage 3: Production]
F --> F1[Start from slim base]
F1 --> F2[COPY --from=builder artifacts only]
F2 --> F3[Set runtime config]
F3 --> G[✅ Final Image]
G --> H[Push to Registry]
What Gets Included vs Excluded
flowchart LR
subgraph builder["Stage 1 — Builder (discarded)"]
B1[gcc / go / maven / npm]
B2[Source code .go .ts .java]
B3[Dev dependencies]
B4[Test files]
B5[Build scripts]
end
subgraph copy["COPY --from=builder"]
C1[compiled binary]
C2[/dist static assets/]
C3[.jar file]
end
subgraph prod["Stage 2 — Production (kept ✅)"]
P1[Slim base OS]
P2[Runtime only]
P3[Your artifact]
P4[Config files]
end
builder --> |only these| copy
copy --> prod
style builder fill:#ffe3e3
style prod fill:#d3f9d8
style copy fill:#fff3bf
4. Anatomy of a Multi-Stage Dockerfile
# ─────────────────────────────────────────────
# STAGE 1: Builder
# ─────────────────────────────────────────────
FROM golang:1.22 AS builder # ← Named stage (use in COPY --from)
WORKDIR /app
# Copy dependency manifests first (better caching)
COPY go.mod go.sum ./
RUN go mod download # ← Cached unless go.mod changes
# Copy source and compile
COPY . .
RUN go build -o /bin/server ./cmd/server
# ─────────────────────────────────────────────
# STAGE 2: Production
# ─────────────────────────────────────────────
FROM gcr.io/distroless/static AS production # ← New base, blank slate
# Pull ONLY the binary from builder
COPY --from=builder /bin/server /server # ← Magic line
EXPOSE 8080
ENTRYPOINT ["/server"]
Key Directives Explained
| Directive | Syntax | Purpose |
|---|---|---|
| Named stage | FROM image AS name |
Give a stage a reusable name |
| Copy from stage | COPY --from=name src dst |
Pull files from another stage |
| Copy from index | COPY --from=0 src dst |
Reference stage by position (fragile) |
| Copy from image | COPY --from=nginx:alpine /etc/nginx.conf / |
Pull from any public image |
| Build target | docker build --target name |
Stop build at a specific stage |
