Docker builds are incremental by default — but only if you structure your Dockerfile to let the cache work. This lesson teaches you to cut build times from minutes to seconds and shrink images from gigabytes to megabytes.
1. How Docker Build Cache Works
Every instruction in a Dockerfile produces a layer. Docker hashes the instruction text plus the content of any files involved. If the hash matches a previously built layer, Docker skips execution and reuses the cached result — a cache hit.
The critical rule: once a layer misses the cache, every subsequent layer is rebuilt from scratch. The cache is a chain — one break invalidates everything downstream.
2. The Order Matters
The golden rule: put things that change least at the top, things that change most at the bottom.
❌ Bad — Copy everything early
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/app.js"]
Any source change → npm install re-runs (45s wasted).
✅ Good — Dependencies first
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/app.js"]
Source change → only COPY . . and build re-run. npm ci stays cached.
Frequency of change should increase as you go down the Dockerfile: base image → system deps → language deps → source code → build step.
3. Cache Busting
The cache is invalidated (busted) when:
- COPY/ADD — any file in the source has changed (content hash, not timestamp)
- RUN — the command string itself changed
- ARG — the argument value changed
- Parent layer — any upstream layer was rebuilt
Sometimes you want to bust the cache intentionally — e.g., to force a fresh apt-get update:
# Force cache bust with a changing ARG
ARG CACHEBUST=1
RUN apt-get update && apt-get install -y curl
# Build with: docker build --build-arg CACHEBUST=$(date +%s) .
RUN apt-get update on its own gets cached. A later RUN apt-get install then uses stale package lists. Always combine them: RUN apt-get update && apt-get install -y pkg.
4. Multi-Stage Builds
The single most impactful optimisation. You build in one stage (with compilers, dev tools, everything), then copy only the final artifact into a minimal runtime image. Build tools never ship to production.
Full multi-stage Dockerfile for a Go application:
# ---- Build Stage ----
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
# ---- Runtime Stage ----
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=build /app /app
EXPOSE 8080
USER 1000
CMD ["/app"]
For statically linked binaries, use FROM scratch as the final stage — literally an empty filesystem. Your image is just your binary, often under 10 MB.
5. .dockerignore
Before building, Docker sends the entire build context (usually your project directory) to the daemon. Without a .dockerignore, this includes massive irrelevant files.
# .dockerignore
.git
node_modules
dist
*.log
.env
.env.*
__pycache__
*.pyc
.DS_Store
coverage
.idea
.vscode
| Metric | Without .dockerignore | With .dockerignore |
|---|---|---|
| Build context sent | #{350 MB} | #{4.2 MB} |
| Time to send context | 8.3s | 0.1s |
| Risk of secret leaks | High (.env copied) | Low |
6. BuildKit Features
BuildKit is Docker's modern build backend (default since Docker 23.0). It adds significant improvements:
- Parallel execution — independent stages build concurrently
- Better caching — mount caches survive between builds
- Secret mounts — inject secrets without baking them into layers
- SSH forwarding — access private repos during build without copying keys
# Enable BuildKit (if not default)
export DOCKER_BUILDKIT=1
# Secret mount — never stored in a layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
# Build command:
docker build --secret id=npmrc,src=$HOME/.npmrc .
# Cache mount — persists package manager cache between builds
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
# SSH mount — clone private repos
RUN --mount=type=ssh git clone git@github.com:company/private-lib.git
CI systems use layer caching to avoid rebuilding from scratch on every commit:
- GitHub Actions —
docker/build-push-actionwithcache-from: type=ghastores layers in GitHub's cache - Registry caching —
--cache-from=type=registry,ref=myrepo:cachepulls cached layers from a registry - BuildKit inline cache —
--build-arg BUILDKIT_INLINE_CACHE=1embeds cache metadata in the image itself
This cuts CI build times from 5–10 minutes to under 30 seconds for typical code changes.
7. Measuring Build Performance
You can't optimise what you don't measure. Key tools:
# See every step's timing with plain output
docker build --progress=plain -t myapp .
# Inspect layer sizes in the final image
docker history myapp
# Example output:
IMAGE CREATED BY SIZE
a1b2c3d4 CMD ["/app"] 0B
e5f6g7h8 COPY --from=build /app /app 12.4MB
i9j0k1l2 RUN apk --no-cache add ca-certs 0.5MB
m3n4o5p6 /bin/sh (alpine base) 7.8MB
# Dive — interactive image layer explorer
# Install: https://github.com/wagoodman/dive
dive myapp
# Shows: layer contents, wasted space, efficiency score
Dive gives an "efficiency score" (0–100%). Aim for 95%+. Common waste: leftover package manager caches, unnecessary build files, duplicate layers.
Interactive Quizzes
Quiz 1: Cache Invalidation
You have this Dockerfile order: FROM → COPY . . → RUN npm install → CMD. You change one line of source code. What happens?
Quiz 2: Multi-Stage Builds
What is the primary benefit of multi-stage builds?
Quiz 3: .dockerignore
What happens if you DON'T have a .dockerignore and your project has a 500MB node_modules directory?
Hands-On Tasks
Start with this poorly-ordered Dockerfile and fix it to maximise cache hits on source code changes:
# Bad order — fix this!
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
Steps:
- Create a project with
package.jsonand asrc/index.tsfile - Build the image and note the time:
docker build --progress=plain -t task1 . - Change
src/index.tsand rebuild — observenpm cire-runs (~30s) - Reorder: move
COPY package.json package-lock.json ./andRUN npm cibeforeCOPY . . - Rebuild after another source change —
npm cishould be cached (build: ~2s)
Expected result: Rebuild drops from ~30s to ~2s for source-only changes.
Convert this single-stage Dockerfile to multi-stage and compare image sizes:
# Single-stage (builds AND ships everything)
FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o /app ./cmd/server
EXPOSE 8080
CMD ["/app"]
Steps:
- Build the single-stage version:
docker build -t app-fat . - Check its size:
docker images app-fat(expect ~800–900 MB) - Create a multi-stage version: build in
golang:1.22, copy binary toalpine:3.19 - Build:
docker build -t app-slim . - Compare:
docker images | grep app-(expect ~12–15 MB for slim) - Verify it runs:
docker run --rm app-slim
Expected result: 98%+ reduction in image size with identical functionality.
Key Takeaways
- Cache is a chain — one broken link rebuilds everything after it
- Order by change frequency — least-changing instructions first
- Separate dependency install from source copy — the single biggest speedup
- Multi-stage builds — build heavy, ship light (90%+ size reduction is typical)
- .dockerignore is free performance — always use it
- BuildKit cache mounts — persist package manager caches across builds
- Measure with --progress=plain and docker history — find the slow layers