Docker Image Layer Caching Strategies for Faster Builds
Learn how Docker's layer cache really works and the ordering tricks that turn a 5-minute build into a 20-second one without sacrificing correctness.
What you'll learn
- ✓How Docker decides whether to reuse a layer
- ✓The right order for COPY and RUN instructions
- ✓Why multi-stage builds shrink images and speed builds
- ✓How BuildKit cache mounts change the game
- ✓Practical patterns for Node, Go, and Java images
Prerequisites
- •Comfort writing basic Dockerfiles
What and Why
Docker builds are deterministic: each instruction in a Dockerfile produces a layer, and identical inputs produce identical layer hashes. Docker reuses layers it has already built. Done well, that means CI builds in seconds. Done poorly, every commit reinstalls 400 MB of npm packages.
The good news: the rules are simple. The bad news: most Dockerfiles violate them.
Mental Model
A Docker image is a stack of read-only filesystem layers. Each RUN, COPY, or ADD adds a new layer. The build cache is keyed on:
- The previous layer’s hash.
- The instruction text.
- For
COPY/ADD, the content hash of the files being copied.
The moment any of those change, that layer and every layer after it are rebuilt. So put slow, rarely-changing steps first; fast, often-changing ones last.
Dockerfile order: Edit src/index.js triggers:
FROM node:20 [cached]
WORKDIR /app [cached]
COPY package.json . [cached] <- deps unchanged
RUN npm ci [cached] <- huge win
COPY . . [REBUILD] <- source changed
RUN npm run build [REBUILD]
CMD ["node","dist"] [REBUILD] Hands-on Example
A naive Node.js Dockerfile:
FROM node:20
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]
Every code change busts the cache before npm ci, so dependencies reinstall every build.
The cache-friendly version:
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
Two wins here. First, npm ci is only re-run when package.json or package-lock.json change. Second, the final image uses node:20-slim and excludes build tools and source files — smaller, more secure, faster to pull.
With BuildKit (enabled by default on modern Docker), cache mounts go even further:
# syntax=docker/dockerfile:1.7
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
The --mount=type=cache keeps the npm cache directory between builds even when the layer itself is invalidated. The first build hydrates the cache; the next one reuses it.
Common Pitfalls
COPY . . too early. This is the number one cache killer. Always copy lockfiles, install dependencies, then copy the rest.
Cache busting with timestamps. RUN echo $(date) > /tmp/x will rebuild every time. Avoid non-deterministic inputs.
Squashing RUN instructions blindly. Combining commands with && reduces layer count and image size, but if any sub-command changes you lose the whole layer’s cache. Group only steps that always change together.
Pulling :latest base images. FROM node:latest invalidates whenever upstream republishes. Pin a digest (FROM node:20.11.1@sha256:abc...) for reproducible builds.
Ignoring .dockerignore. Without one, a stray log file or node_modules change in your repo invalidates COPY . .. A good .dockerignore mirrors .gitignore plus build artifacts.
Practical Tips
For Go binaries, use a similar two-stage pattern with module cache:
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
For Maven or Gradle, copy the build descriptors first and run a dependency-only resolve step (mvn dependency:go-offline or gradle --no-daemon dependencies) before copying source.
In CI, share cache between runners with registry-backed cache:
docker buildx build \
--cache-from type=registry,ref=myorg/app:buildcache \
--cache-to type=registry,ref=myorg/app:buildcache,mode=max \
-t myorg/app:latest --push .
This makes ephemeral CI runners feel like a long-lived developer laptop.
Wrap-up
Layer caching is one of the highest-leverage Docker skills. Order instructions from least to most likely to change, separate dependency installation from source copy, use multi-stage builds to slim the final image, and lean on BuildKit cache mounts and registry cache in CI. Once your Dockerfile respects these rules, builds get fast, deployments get smaller, and your CI bill goes down without any code changes.
Related articles
- Docker Docker Build Context and .dockerignore Tips
Why your Docker builds are slow and your images are bloated, and how to fix both with a tight build context and a thoughtful .dockerignore.
- Docker Docker Healthchecks and Restart Policies Explained
Healthchecks tell Docker if a container is alive. Restart policies tell it what to do when it is not. Together they keep your services running.
- Docker Docker Networking: Bridge, Host, and Overlay Networks Explained
A clear guide to Docker's three most common network drivers, when to pick each one, and how packets actually flow between containers in real deployments.
- Docker Docker Security Best Practices for Production
Harden Docker images and runtimes: non-root users, minimal bases, secret handling, capability drops, and image signing - without making developers miserable.