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

ApproachSize
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.