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.

Cache Hit vs Cache Miss ✓ All Cached (no changes) FROM node:20 — cached ✓ RUN apt-get install — cached ✓ COPY package.json — cached ✓ RUN npm install — cached ✓ COPY . . — cached ✓ ⏱️ Build time: <1s ✗ Source changed → cascade FROM node:20 — cached ✓ RUN apt-get install — cached ✓ COPY . . — CHANGED ✗ RUN npm install — rebuilt ✗ CMD node app.js — rebuilt ✗ ⏱️ Build time: 45s (npm install re-runs!)

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.

Rule of Thumb

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) .
Don't Separate update and install

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.

Stage 1: Build FROM golang:1.22 COPY . . RUN go build -o /app /app (12 MB binary) Total stage: ~800 MB COPY --from Stage 2: Runtime FROM alpine:3.19 COPY --from=build /app /app CMD ["/app"] /app (12 MB binary) Final image: ~12 MB 🎉

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"]
Use scratch for maximum minimalism

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
MetricWithout .dockerignoreWith .dockerignore
Build context sent#{350 MB}#{4.2 MB}
Time to send context8.3s0.1s
Risk of secret leaksHigh (.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
🏭 Industry: CI Layer Caching

CI systems use layer caching to avoid rebuilding from scratch on every commit:

  • GitHub Actionsdocker/build-push-action with cache-from: type=gha stores layers in GitHub's cache
  • Registry caching--cache-from=type=registry,ref=myrepo:cache pulls cached layers from a registry
  • BuildKit inline cache--build-arg BUILDKIT_INLINE_CACHE=1 embeds 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
Target a 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?

  • Only the CMD layer is rebuilt
  • COPY, npm install, and CMD are all rebuilt
  • Only COPY is rebuilt, npm install stays cached
  • Nothing is rebuilt — Docker detects only source changed

Quiz 2: Multi-Stage Builds

What is the primary benefit of multi-stage builds?

  • They make builds faster by parallelising all stages
  • They allow using multiple base images simultaneously at runtime
  • They produce smaller final images by excluding build-time tools and dependencies
  • They enable running multiple processes in one container

Quiz 3: .dockerignore

What happens if you DON'T have a .dockerignore and your project has a 500MB node_modules directory?

  • Docker automatically ignores node_modules
  • The entire 500MB is sent as build context, slowing the build even if you never COPY it
  • The build fails with a context size error
  • node_modules is only sent if COPY . . is used

Hands-On Tasks

🛠️ Task 1: Reorder for Cache Efficiency

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:

  1. Create a project with package.json and a src/index.ts file
  2. Build the image and note the time: docker build --progress=plain -t task1 .
  3. Change src/index.ts and rebuild — observe npm ci re-runs (~30s)
  4. Reorder: move COPY package.json package-lock.json ./ and RUN npm ci before COPY . .
  5. Rebuild after another source change — npm ci should be cached (build: ~2s)

Expected result: Rebuild drops from ~30s to ~2s for source-only changes.

🛠️ Task 2: Multi-Stage Image Slimming

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:

  1. Build the single-stage version: docker build -t app-fat .
  2. Check its size: docker images app-fat (expect ~800–900 MB)
  3. Create a multi-stage version: build in golang:1.22, copy binary to alpine:3.19
  4. Build: docker build -t app-slim .
  5. Compare: docker images | grep app- (expect ~12–15 MB for slim)
  6. Verify it runs: docker run --rm app-slim

Expected result: 98%+ reduction in image size with identical functionality.

Key Takeaways

Remember These
  • 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