Docker Multi-Stage Builds & Image Optimisation
Shrink production images by 10x using multi-stage builds, distroless and Alpine bases, layer cache ordering, .dockerignore, build secrets, and non-root users — the practices that separate production images from dev images.
Before you begin
- Understand Docker basics — images, containers, Dockerfile instructions
- Can build and run a Docker image locally
Docker Multi-Stage Builds & Image Optimisation
A poorly built image carries your entire build toolchain into production: compilers, test frameworks, package manager caches. A typical unoptimised Node.js image is 1.2GB. An optimised one is under 100MB. That difference matters for pull time in CI, attack surface, registry storage costs, and Kubernetes node startup.
This tutorial covers the techniques that make production images small, fast, and secure.
Why Image Size Matters
- Pull time — In Kubernetes, every new node must pull images. A 1GB image takes 30–60 seconds to pull on a cold node; a 50MB image takes under 2 seconds. This directly affects pod startup time.
- Attack surface — Every binary in an image is a potential exploit vector. A compiler or package manager has no business being in a production container.
- Registry costs — Pushing and pulling gigabytes across regions adds up.
- Layer cache effectiveness — Small, focused layers hit cache more often; bloated layers invalidate it frequently.
Layer Caching — The Most Impactful Optimisation
Docker builds images layer by layer. When you rebuild, Docker checks each layer: if the instruction and its inputs haven't changed, it reuses the cached layer. If one layer changes, all subsequent layers are invalidated.
The rule: put things that change rarely at the top, things that change often at the bottom.
1# Bad — code changes invalidate the dependency install layer
2FROM node:20-alpine
3WORKDIR /app
4COPY . . # ← copies everything including code
5RUN npm ci # ← always re-runs because code changed
6
7# Good — dependency layer is cached until package.json changes
8FROM node:20-alpine
9WORKDIR /app
10COPY package*.json ./ # ← only copy dependency manifests first
11RUN npm ci # ← cached unless package.json changed
12COPY . . # ← code copied last; only this layer rebuildsThis single reordering can cut your build from 2 minutes to 5 seconds on a code-only change.
The same pattern for other ecosystems:
1# Python
2COPY requirements.txt .
3RUN pip install -r requirements.txt
4COPY . .
5
6# Go
7COPY go.mod go.sum ./
8RUN go mod download
9COPY . .
10
11# Java (Maven)
12COPY pom.xml .
13RUN mvn dependency:go-offline
14COPY src ./srcMulti-Stage Builds
A single Dockerfile can define multiple FROM stages. Each stage starts fresh. You copy only specific artifacts from earlier stages into the final image — leaving build tools behind.
Example: Go application
1# ── Stage 1: build ──────────────────────────────────────────────────────────
2FROM golang:1.22-alpine AS builder
3WORKDIR /app
4COPY go.mod go.sum ./
5RUN go mod download
6COPY . .
7RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
8
9# ── Stage 2: production image ────────────────────────────────────────────────
10FROM scratch
11COPY --from=builder /app/server /server
12EXPOSE 8080
13ENTRYPOINT ["/server"]The final image contains only the compiled binary. scratch is an empty image — no shell, no OS libraries. The image is typically 5–15MB vs 400MB+ for the build stage.
Example: Node.js application
1# ── Stage 1: install dependencies ───────────────────────────────────────────
2FROM node:20-alpine AS deps
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci --omit=dev
6
7# ── Stage 2: build (TypeScript, bundler, etc.) ───────────────────────────────
8FROM node:20-alpine AS build
9WORKDIR /app
10COPY package*.json ./
11RUN npm ci
12COPY . .
13RUN npm run build
14
15# ── Stage 3: production ──────────────────────────────────────────────────────
16FROM node:20-alpine AS production
17WORKDIR /app
18COPY --from=deps /app/node_modules ./node_modules
19COPY --from=build /app/dist ./dist
20COPY package.json .
21EXPOSE 3000
22USER node
23CMD ["node", "dist/server.js"]The production image has no dev dependencies, no TypeScript compiler, no source files.
Targeting a specific stage
docker build --target builder -t myapp:debug . # Build only up to the builder stage
docker build --target production -t myapp:prod .This is useful for running tests in the build stage without bloating the final image.
Choosing a Base Image
| Base | Size | Use case |
|---|---|---|
ubuntu:22.04 | ~80MB | General purpose, familiar tools |
debian:bookworm-slim | ~75MB | Debian without extras |
alpine:3.20 | ~5MB | Smallest general-purpose Linux |
distroless/static | ~2MB | Static binaries only (Go, Rust) |
distroless/base | ~20MB | Binaries with glibc |
distroless/nodejs | ~75MB | Node.js without shell |
scratch | 0MB | Truly static binaries |
Alpine caveats
Alpine uses musl libc instead of glibc. Most software works fine, but:
- Some Go binaries require CGO — use
CGO_ENABLED=0for static builds - Python packages with C extensions may fail or need extra packages (
gcc,musl-dev) - Some Node.js native modules have issues
For Go and Rust: prefer scratch or distroless/static. For Python: python:3.12-slim (Debian slim) is safer than Alpine.
Distroless images
Google's distroless images contain only the runtime (libc, OpenSSL, CA certs) — no shell, no package manager, no utilities. This dramatically reduces attack surface.
1FROM golang:1.22 AS builder
2WORKDIR /app
3COPY . .
4RUN CGO_ENABLED=0 go build -o server .
5
6FROM gcr.io/distroless/static-debian12
7COPY --from=builder /app/server /server
8ENTRYPOINT ["/server"]The tradeoff: you can't docker exec ... bash for debugging. Use a debug variant for troubleshooting:
docker run gcr.io/distroless/static-debian12:debug # includes busybox shellNon-Root Users
Running as root inside a container means that a container escape gives root on the host. Always drop privileges.
1FROM node:20-alpine
2WORKDIR /app
3COPY --chown=node:node package*.json ./
4RUN npm ci --omit=dev
5COPY --chown=node:node . .
6USER node # Switch to non-root before CMD
7CMD ["node", "server.js"]For images without a pre-created user:
FROM python:3.12-slim
RUN useradd -r -u 1001 -g root appuser
WORKDIR /app
COPY --chown=appuser:root . .
USER appuser
CMD ["python", "app.py"]Check what user a container runs as:
docker run --rm myimage whoami
docker inspect myimage | jq '.[0].Config.User'Build-Time Secrets
Never bake credentials into image layers. Even if a later RUN deletes a secret, it persists in the layer history.
# Wrong — the token is baked into layer history
RUN pip install --extra-index-url https://token:${PYPI_TOKEN}@private.pypi.org/simple/ package
# Right — use BuildKit secret mounts (never appear in layers)
RUN --mount=type=secret,id=pypi_token \
pip install --extra-index-url https://token:$(cat /run/secrets/pypi_token)@private.pypi.org/simple/ packagedocker build --secret id=pypi_token,src=$HOME/.pypi-token .For SSH keys (private repos):
RUN --mount=type=ssh go mod download
docker build --ssh default ..dockerignore — Keep the Build Context Clean
The build context is everything Docker sends to the daemon before building. A bloated context slows every build and risks including secrets.
# .dockerignore
.git
.gitignore
.env
.env.*
node_modules
dist
build
*.log
*.md
.DS_Store
__pycache__
.pytest_cache
.coverage
.venv
terraform/.terraform
Check your context size (BuildKit, the default since Docker 23.0, transfers context silently — use --progress=plain to see it):
docker build --no-cache --progress=plain . 2>&1 | grep "transferring context"
# #3 transferring context: 2.05kB done ← good
# #3 transferring context: 1.23GB done ← missing .dockerignoreInspecting Images
docker history — see all layers
docker history myapp:latest
# IMAGE CREATED CREATED BY SIZE
# a3f8b2c9d1e4 2 hours ago CMD ["node" "dist/server.js"] 0B
# 8f1e3a7c2b50 2 hours ago COPY --from=build /app/dist ./dist 1.2MBLayers with 0B are metadata (ENV, EXPOSE, CMD). Large RUN layers often contain avoidable cache files.
dive — interactive layer explorer
brew install dive
dive myapp:latestdive shows you exactly which files are in each layer, making it easy to find what's bloating your image.
A Real Before/After
Before (unoptimised Node.js):
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]Size: ~1.1GB
After (optimised):
1FROM node:20-alpine AS deps
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci --omit=dev
5
6FROM node:20-alpine AS build
7WORKDIR /app
8COPY package*.json ./
9RUN npm ci
10COPY . .
11RUN npm run build
12
13FROM node:20-alpine
14WORKDIR /app
15COPY --from=deps /app/node_modules ./node_modules
16COPY --from=build /app/dist ./dist
17USER node
18CMD ["node", "dist/server.js"]Size: ~85MB — a 13x reduction.
What's Next
- Docker Compose for Local Development — wire multiple containers together with service discovery, health checks, and environment config
- Once you understand images and Compose, you're ready for Kubernetes — where containers run at scale, orchestrated across nodes
These tutorials are part of the Container Foundations learning path. The patterns here — small images, non-root users, no secrets in layers — apply directly to Kubernetes workloads.
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.