Your Docker images are probably too big. Build tools, dev dependencies, source files—all shipping to production when only the compiled binary matters. Multi-stage builds fix this by separating build environment from runtime environment.
The Problem#
A typical single-stage Dockerfile:
1
2
3
4
5
6
7
8
9
| FROM python:3.11
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
|
This image includes:
- Full Python installation (~900MB)
- pip and setuptools
- All your source files
- Build artifacts and cache
- Potentially your
.git directory
Result: A 1.2GB image for a 50MB application.
The Solution: Multi-Stage Builds#
Separate building from running:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| # Stage 1: Build
FROM python:3.11 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
COPY . .
# Stage 2: Runtime
FROM python:3.11-slim
WORKDIR /app
# Copy only what we need from builder
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app/*.py /app/
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
|
Result: ~150MB instead of 1.2GB.
Go: The Ideal Case#
Go’s static binaries make this even better:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| # Build stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server .
# Runtime stage - scratch is empty!
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]
|
Result: ~10-20MB image containing just your binary and CA certificates.
Node.js: Production Dependencies Only#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| # Build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-slim
WORKDIR /app
# Only production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy built assets
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
|
Dev dependencies (TypeScript, test frameworks, linters) stay in the build stage.
Rust: Compile Once, Run Anywhere#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| # Build stage
FROM rust:1.75 AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp /usr/local/bin/
CMD ["myapp"]
|
Or use scratch for fully static builds with musl:
1
2
3
4
5
6
7
8
9
10
11
12
| FROM rust:1.75 AS builder
RUN rustup target add x86_64-unknown-linux-musl
RUN apt-get update && apt-get install -y musl-tools
WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /
CMD ["/myapp"]
|
Caching Dependencies#
Layer ordering matters for cache efficiency:
1
2
3
4
5
6
7
8
9
10
| FROM python:3.11 AS builder
WORKDIR /app
# Dependencies change less often than code
COPY requirements.txt .
RUN pip install --user -r requirements.txt
# Code changes frequently
COPY . .
|
If you COPY . . before installing dependencies, every code change invalidates the pip cache.
Named Stages for Clarity#
Use names instead of indices:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| FROM node:20 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20 AS tester
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm test
FROM node:20-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
|
Build specific stages:
1
2
| docker build --target tester -t myapp:test .
docker build --target runner -t myapp:prod .
|
Security Scanning Stage#
Add vulnerability scanning as a stage:
1
2
3
4
5
6
| FROM aquasec/trivy AS scanner
COPY --from=builder /app/server /scan/server
RUN trivy filesystem --exit-code 1 --severity HIGH,CRITICAL /scan
FROM scratch AS final
COPY --from=builder /app/server /server
|
The build fails if critical vulnerabilities are found.
BuildKit Secrets#
Never put secrets in your Dockerfile:
1
2
3
4
5
6
7
| # syntax=docker/dockerfile:1.4
FROM python:3.11 AS builder
# Mount secret at build time, not baked into layer
RUN --mount=type=secret,id=pip_conf,target=/root/.pip/pip.conf \
pip install --user -r requirements.txt
|
Build with:
1
| DOCKER_BUILDKIT=1 docker build --secret id=pip_conf,src=./pip.conf .
|
Size Comparison#
| Language | Single Stage | Multi-Stage | Reduction |
|---|
| Python | 1.2GB | 150MB | 88% |
| Node.js | 1.1GB | 200MB | 82% |
| Go | 800MB | 15MB | 98% |
| Rust | 1.5GB | 20MB | 99% |
Quick Checklist#
Before shipping your Dockerfile:
Smaller images mean faster pulls, faster deploys, smaller attack surface, and lower storage costs. Multi-stage builds get you there without sacrificing your development workflow.