Docker Build Cache Not Working? 7 Fixes for Faster CI/CD Builds
If your pipeline rebuilds every layer from scratch even when nothing changed, you're burning CI minutes and patience for no reason. Here's what's actually going wrong and how to fix it.
There's a specific kind of frustration that comes from watching a CI pipeline reinstall the exact same npm packages it installed six minutes ago, on a commit that only touched a README file. You know the cache should have kicked in. It didn't. Multiply that by fifty commits a day across a team and you've quietly burned hours of engineering time and a real chunk of your CI bill on nothing.
I've debugged this exact problem across GitHub Actions, GitLab CI, and Jenkins runners for teams shipping Node, Go, and Python services, and it's almost never "Docker is broken." It's almost always one of a handful of predictable misconfigurations. This post walks through why the cache breaks in the first place and gives you seven concrete fixes, in the order I'd actually try them.
Why Docker's Build Cache Breaks in the First Place
Docker builds images layer by layer, and each layer gets a cache key derived from the instruction and its inputs. As long as an instruction and everything it depends on stays identical, Docker reuses the cached layer instead of re-executing it. That's the theory. In practice, the cache is far more fragile than people assume, for a few structural reasons.
First, the classic Docker builder (the "legacy" builder, pre-BuildKit) only ever caches locally, on the machine that ran the build. CI runners are usually ephemeral — a fresh VM or container spins up for every job, builds your image, then disappears. There's no local cache to hit because there's no "local" that persists between runs. This single fact explains probably 60% of the "my cache isn't working in CI" tickets I've seen.
Second, cache invalidation in Docker is strictly sequential. The moment one layer's cache misses, every layer after it misses too, even if those later layers didn't logically depend on the change. Put your `COPY . .` above your `RUN npm install` and you've guaranteed a full dependency reinstall on every single source code change, because Docker has no way to know your package.json didn't move.
Third, BuildKit changed a lot of the caching model — for the better, mostly — but it also means teams running mixed toolchains (some using `docker build`, others using `buildx`, others running old CI images with Docker 19.x) get inconsistent behavior depending on which builder actually executes the job. If your fix works on your laptop and does nothing in CI, check which builder is actually running there before you touch anything else.
Seven Fixes, in the Order I'd Actually Apply Them
Switch to BuildKit
Enable the modern builder before doing anything else.
Fix layer order
Dependencies before source code, always.
Use remote cache
--cache-from / --cache-to against a registry.
Clean up .dockerignore
Stop invalidating layers with junk context.
Split multi-stage builds
Isolate volatile stages from stable ones.
Cache mounts for package managers
Persist npm/pip/go caches across builds.
Pin base image digests
Stop silent cache busts from tag drift.
Fix 1 — Make Sure BuildKit Is Actually Running
This sounds obvious, but I still run into runners in 2026 defaulting to the legacy builder because someone pinned an old Docker CLI version two years ago and nobody revisited it. BuildKit gives you parallel stage execution, better cache mount support, and the `--cache-from`/`--cache-to` flags that make remote caching possible at all.
If `docker buildx version` throws an error, your runner image doesn't have Buildx installed at all — that's your actual problem, not the cache config you were about to spend an hour tweaking.
Fix 2 — Reorder Your Dockerfile So Cheap, Stable Layers Come First
This is the single highest-leverage fix and it costs nothing. Docker invalidates a layer and everything below it the moment an instruction's input changes. So the instructions least likely to change — installing OS packages, installing dependencies — belong at the top. Your actual application source, which changes on every commit, belongs as close to the bottom as you can get it.
I've seen this one change take a Node service's average build time from 4 minutes down to 45 seconds on unchanged-dependency commits — which, realistically, is most commits on most days.
Fix 3 — Use Registry-Based Remote Cache (`--cache-from` / `--cache-to`)
Since CI runners don't share local disk, you need to externalize the cache to somewhere both today's runner and tomorrow's runner can reach. That's your container registry — GHCR, ECR, Docker Hub, GCR, whatever you're already pushing images to.
mode=max matters here — the default (min) only caches the final image layers, not the intermediate stages in a multi-stage build. If you're using multi-stage builds (and you probably should be), mode=max is what actually lets you skip re-running an earlier build stage.
Fix 4 — Stop Sending Garbage as Build Context
Every file in your build context gets hashed and factored into cache decisions, even files your Dockerfile never touches directly, because the whole context gets sent to the daemon before the build starts. A missing or sloppy `.dockerignore` means your `node_modules`, `.git` history, log files, and local `.env` files are all part of what Docker considers when deciding whether anything "changed."
I've seen a `.git` directory alone add 40+ seconds to context transfer on a mature repo, and worse, it made every build's context hash technically different because commit metadata churns constantly. That's a cache miss hiding in plain sight.
Fix 5 — Separate Volatile and Stable Stages in Multi-Stage Builds
If you're compiling a Go binary or bundling a frontend in one stage and copying only the artifact into a slim runtime image, keep the dependency-fetch step and the compile step in genuinely separate, ordered layers within that build stage — same rule as Fix 2, just applied per-stage instead of globally.
go mod download only reruns when go.mod/go.sum change, and the final distroless stage never carries the Go toolchain at all. Smaller image, fewer CVEs to explain to security, faster pulls at deploy time.
Fix 6 — Use BuildKit Cache Mounts for Package Manager Directories
This one's underused. BuildKit lets you mount a persistent cache directory into a `RUN` step that survives across builds without becoming part of the image layer itself — meaning your package manager's own internal cache (npm's, pip's, apt's) sticks around even when the layer above it changes.
This is genuinely useful even when the dependency file itself changes — adding one new package no longer means re-downloading every package in the tree, because npm's own download cache persists in that mount.
Fix 7 — Pin Base Images by Digest, Not Just Tag
Floating tags like `node:20-slim` get republished. When the upstream maintainer pushes a new build under the same tag, your "identical" `FROM` instruction now resolves to a different underlying image, and Docker treats that as a cache-busting change — sometimes silently, sometimes inconsistently between runners that pulled the tag at different times.
Comparing the Caching Approaches
| Approach | Works across CI runners? | Setup effort | Best for |
|---|---|---|---|
| Legacy builder, local cache only | No | None (default) | Local dev only |
BuildKit + registry cache (--cache-from/--cache-to) | Yes | Low | Most CI/CD pipelines |
GitHub Actions cache backend (type=gha) | Yes, GH-only | Low | Teams fully on GitHub Actions |
| BuildKit cache mounts | Depends on runner persistence | Low | Package manager downloads specifically |
| Self-hosted runner with persistent disk | Yes, if runner is sticky | Medium-high | High-frequency builds, cost-sensitive teams |
A Quick Real-World Example
On a mid-sized Node monorepo I worked on, the pipeline was averaging 6 minutes 40 seconds per build regardless of what changed — full dependency reinstall, every time, because the Dockerfile had `COPY . .` before `npm ci` and CI was still running the legacy builder from an old runner image. Reordering the Dockerfile, switching to `DOCKER_BUILDKIT=1`, and wiring up registry-based remote cache with `mode=max` dropped unchanged-dependency builds to roughly 50 seconds. That's not a marginal improvement — that's the difference between a pipeline developers tolerate and one they actively resent.
Common Mistakes That Undo All of This
- Running builds on ephemeral GitHub-hosted runners without any remote cache configured, then wondering why "the cache never sticks."
- Using `ADD` instead of `COPY` for local files — `ADD` has extra behavior (URL fetching, auto-extraction) that isn't needed here and adds noise to what should be a simple, predictable operation.
- Rebuilding the cache image itself on every branch with a unique tag, which means the cache never actually gets reused because nothing else references that exact tag again.
- Forgetting `mode=max` and assuming intermediate build-stage layers are cached when they're not.
- Mixing Docker CLI versions between local dev and CI, then debugging cache behavior that only exists on one of them.
Wrapping Up
None of these seven fixes are exotic. That's kind of the point — Docker's caching model is genuinely reliable once you stop fighting the two things that break it most often: no persistent cache location in CI, and Dockerfiles ordered for readability instead of cache efficiency. Start with reordering your Dockerfile and wiring up a registry cache with BuildKit; that combination alone solves the majority of "why does everything rebuild" tickets I've seen across different teams and stacks. The rest — cache mounts, digest pinning, stage separation — are refinements you layer on once the basics are actually working.
Frequently Asked Questions
Why does my Docker cache work locally but not in CI?
Almost always because your local machine has a persistent Docker cache from previous builds, while your CI runner spins up fresh every time with nothing cached. You need to explicitly export the cache to a registry with --cache-to and pull it back with --cache-from on the next run.
Does changing one line of application code really invalidate the whole cache?
It invalidates every layer from that `COPY` instruction downward — not layers above it. That's exactly why dependency installation should happen before you copy your source code into the image.
What's the difference between mode=min and mode=max for cache export?
mode=min only exports the cache for the final image layers. mode=max exports cache for every intermediate layer, including earlier stages in a multi-stage build. If you're using multi-stage builds, mode=max is usually what you actually want.
Do BuildKit cache mounts get included in the final image?
No. Cache mounts are only available during the `RUN` instruction they're attached to and are not committed to the resulting image layer, so they don't bloat your final image size.
Is it safe to pin base images by digest instead of tag?
Yes, and it's a good practice for reproducibility, but it means you won't automatically pick up security patches from the base image maintainer. Pin for stability, then set a recurring reminder to review and bump digests.
Why does GitHub Actions have its own cache type (type=gha)?
GitHub Actions provides a built-in cache backend that's often faster to set up than a registry-based cache because it doesn't require pushing cache layers to an external registry — it uses GitHub's own cache storage instead, scoped to your repo and workflow.
Can I use registry cache and cache mounts together?
Yes, and it's a good combination — registry cache handles layer-level reuse across machines, while cache mounts speed up what happens inside a `RUN` step even on a full cache miss for that layer.
Comments