Skip to content
Discovery DevOps 3 min read · Updated 5 Aug 2026

Docker Layer Caching: Why Your Build Is Slow

beginner dockercicaching

A Docker build that takes eight minutes on every commit is almost never doing eight minutes of necessary work. It is redoing work it already did, because something told it the cached result was no longer valid.

The rule that governs this is short: a layer is reused only if its instruction is unchanged and every layer before it was also reused. Cache invalidation cascades forward and never recovers.

Reading a Dockerfile as a cache chain

FROM node:22-slim          # layer 1
WORKDIR /app               # layer 2
COPY . .                   # layer 3  ← changes on every commit
RUN npm ci                 # layer 4  ← therefore always re-runs
RUN npm run build          # layer 5  ← therefore always re-runs
CMD ["node", "dist/main"]  # layer 6

Change one character in one source file and layer 3 is invalid. Layers 4 and 5 are then invalid by rule, even though package.json did not move and the install would have produced an identical result. You reinstall the world on every commit.

The fix is to copy the thing that rarely changes, separately, first:

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./   # only changes when deps change
RUN npm ci                               # cached until they do
COPY . .                                 # changes constantly — but it is last
RUN npm run build
CMD ["node", "dist/main"]

Now a normal code change invalidates only the copy and the build. The install — the expensive part — is reused.

What actually counts as “changed”

For RUN, Docker compares the command string only. It does not know or care what the command does — which is why RUN apt-get update can serve you a cached package list from weeks ago, and why pinning versions matters more than it seems.

For COPY and ADD, Docker compares the contents and metadata of the files being copied. This is where builds break invisibly: if your build context includes .git, node_modules, logs or test output, then a file nobody cares about changes the hash and busts the cache.

A .dockerignore is therefore a cache optimisation as much as an image-size one:

.git
node_modules
dist
*.log
.env*

It also shrinks the context that gets uploaded to the daemon before the build even begins, which on a large repo is often several seconds of pure waiting.

Cache mounts: for the parts a layer cannot hold

Even a perfectly ordered Dockerfile re-downloads every package whenever a dependency does change. BuildKit cache mounts keep the package manager’s cache directory across builds, outside the layer system:

RUN --mount=type=cache,target=/root/.npm \
    npm ci

The directory persists between builds but is not part of the image. A changed lockfile now re-runs the install, but from a warm local cache rather than the network. The same pattern applies to ~/.cache/pip, /root/.m2, /go/pkg/mod and Cargo’s registry.

The CI problem: caches that never exist

All of this assumes previous layers are present. On a fresh CI runner they are not — every build starts cold, so a locally instant build takes eight minutes in CI and everyone concludes Docker is slow.

You have to import and export the cache explicitly:

docker buildx build \
  --cache-from type=registry,ref=ghcr.io/me/app:buildcache \
  --cache-to   type=registry,ref=ghcr.io/me/app:buildcache,mode=max \
  -t ghcr.io/me/app:latest --push .

mode=max exports intermediate layers too, not just the final ones — for multi-stage builds that is the difference between a useful cache and a decorative one.

When you have fixed the order and it is still slow

Look at what the build is actually doing rather than at the cache. Two things dominate real builds: a base image far larger than needed (node:22 is around 1.1 GB; node:22-slim is roughly a fifth of that), and compilation that belongs in a builder stage whose output is copied into a clean runtime image.

That second one is a separate technique with its own trade-offs — see multi-stage builds, which is what you reach for once ordering alone stops paying.

What you have actually built

Not a faster build. The same build, ordered so that the parts which rarely change are not asked to happen again.

The failure mode is worth naming precisely, because it recurs everywhere: something cheap and volatile was placed in front of something expensive and stable, and made the expensive thing volatile too. That is a caching bug, not a Docker bug — and it is the same mistake as putting a timestamp at the top of an LLM prompt.

Quick answers

How does Docker layer caching work?
Each Dockerfile instruction creates a layer. On rebuild, Docker reuses a cached layer if the instruction is identical and every layer before it was also reused. The first change invalidates that layer and every layer after it, regardless of whether they were affected.
Why is my Docker build not using the cache?
Most often because a COPY of your whole source tree appears before the dependency install, so any code change invalidates the install step. Other causes are a missing .dockerignore letting noise into the build context, or CI starting from an empty cache on every run.
What should come first in a Dockerfile?
Order instructions from least to most frequently changing: base image, system packages, dependency manifests, dependency install, then application source last. That way a code change only invalidates the final cheap layers.
Does COPY . . break the Docker cache?
It invalidates every layer after it whenever any copied file changes, which is on virtually every commit. That is fine as the last step, and ruinous before an expensive install. Copy only the manifests first, install, then copy the rest.

References

Related Discoveries