Multi-Stage Docker Builds: Smaller Images, Faster Deploys
The Problem with Single-Stage Builds
A naive Dockerfile installs build tools, compiles the application, and ships everything:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["node", "dist/server.js"]
The resulting image contains node_modules (including devDependencies), the TypeScript compiler, source maps, and every build tool. For a typical web app this is 1-2 GB when it could be 100-200 MB.
---
Multi-Stage Build: Node.js
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # install ALL deps (including dev)
COPY . .
RUN npm run build # compile TypeScript to dist/
# Stage 2: production
FROM node:20-alpine AS prod
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # only production deps
COPY --from=builder /app/dist ./dist # copy compiled output only
USER node
CMD ["node", "dist/server.js"]
The final image contains only the compiled JS and production node_modules. Build tools, source files, and devDependencies are left in the builder stage and discarded.
---
Multi-Stage Build: Go
Go compiles to a static binary, making the production stage even leaner:
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
FROM scratch AS prod # empty base image
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]
Final image: ~15 MB (just the binary and TLS certs). No OS, no shell, no attack surface.
---
Multi-Stage Build: Python
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim AS prod
COPY --from=builder /install /usr/local
WORKDIR /app
COPY src/ ./src/
USER nobody
CMD ["python", "-m", "src.main"]
---
Targeting a Specific Stage
# Build only the builder stage (for CI artifact caching)
docker build --target builder -t myapp:builder .
# Build the prod stage (default)
docker build -t myapp:latest .
This is useful for running tests in the builder stage and only pushing the prod stage.
---
Build Cache Optimization
Layer order matters. Dependencies change less often than source code:
# Good: copy dependency files first, source second
COPY package*.json ./
RUN npm ci
COPY src/ ./src/ # cache miss here only when source changes
# Bad: copy everything first
COPY . . # cache miss on every source change
RUN npm ci # reinstalls all deps every time
---
Image Size Comparison
| Approach | Size |
|---|---|
| Single-stage Node.js | ~1.2 GB |
| Multi-stage Node.js | ~180 MB |
| Multi-stage Go | ~15 MB |
| Multi-stage Python | ~120 MB |
Practice in the ShellGenius Docker Labs — the multistage-build and image-optimize challenges walk through real optimization scenarios.