DevOps & Platform

Docker Multi-Stage Builds & Image Optimisation

Intermediate45 min to complete14 min read

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
Security
Distroless
DevOps

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

  1. 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.
  2. 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.
  3. Registry costs — Pushing and pulling gigabytes across regions adds up.
  4. 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.

dockerfile
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 rebuilds

This single reordering can cut your build from 2 minutes to 5 seconds on a code-only change.

The same pattern for other ecosystems:

dockerfile
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 ./src

Multi-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

dockerfile
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

dockerfile
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

bash
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

BaseSizeUse case
ubuntu:22.04~80MBGeneral purpose, familiar tools
debian:bookworm-slim~75MBDebian without extras
alpine:3.20~5MBSmallest general-purpose Linux
distroless/static~2MBStatic binaries only (Go, Rust)
distroless/base~20MBBinaries with glibc
distroless/nodejs~75MBNode.js without shell
scratch0MBTruly static binaries

Alpine caveats

Alpine uses musl libc instead of glibc. Most software works fine, but:

  • Some Go binaries require CGO — use CGO_ENABLED=0 for 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.

dockerfile
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:

bash
docker run gcr.io/distroless/static-debian12:debug   # includes busybox shell

Non-Root Users

Running as root inside a container means that a container escape gives root on the host. Always drop privileges.

dockerfile
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:

dockerfile
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:

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

bash
# 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/ package
bash
docker build --secret id=pypi_token,src=$HOME/.pypi-token .

For SSH keys (private repos):

bash
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):

bash
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 .dockerignore

Inspecting Images

docker history — see all layers

bash
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.2MB

Layers with 0B are metadata (ENV, EXPOSE, CMD). Large RUN layers often contain avoidable cache files.

dive — interactive layer explorer

bash
brew install dive
dive myapp:latest

dive 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):

dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

Size: ~1.1GB

After (optimised):

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