A Docker build that recompiles everything on every commit, including dependencies untouched for weeks, loses the whole point of caching: every layer in a Dockerfile is cached independently, but a single wrongly invalidated layer cancels the cache of every layer after it, no matter how sophisticated the cache backend behind it is.
One layer, one cache entry, cascading invalidation
Docker (and BuildKit, its modern build engine) caches every Dockerfile instruction separately, but invalidation cascades: the moment one layer changes, every following layer rebuilds too, even if it had no reason to change.
# Bad order: source code changes on every commit, so
# npm install reruns every time, no matter what
COPY . .
RUN npm install
# Good order: dependencies only change if package.json
# changes, npm install stays cached the rest of the time
COPY package.json package-lock.json ./
RUN npm install
COPY . .
The second order isolates what rarely changes (dependencies) from what changes on every commit (source code), so only the genuinely modified part rebuilds. That basic rule comes before any tooling question: bad layer order makes even the most sophisticated cache backend pointless.
Default cache doesn’t survive a fresh runner
A local Docker cache (the one implicitly built during a build) lives on the machine that ran the build. On a hosted CI runner, spinning up a fresh machine on every run (or nearly so), that local cache systematically disappears: every build starts from zero, no reusable layer, regardless of Dockerfile order.
# Without external cache: every CI run rebuilds everything,
# good layer order does nothing on this kind of runner
- uses: docker/build-push-action@v6
with:
context: .
GitHub Actions cache: an external backend that survives between runs
cache-from/cache-to with the gha backend externalizes the cache to GitHub Actions’ native cache storage, which persists between runs even on ephemeral hosted runners. It’s exactly the mechanism this site uses in its own deployment pipeline:
- uses: docker/build-push-action@v6
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=max
mode=max deserves understanding before being blindly copied: the default mode only caches the final stage’s layers of a multi-stage build, while mode=max also keeps intermediate layers (dependencies installed in a separate build stage, for instance). mode=max improves the cache hit rate on a typical multi-stage build, at the cost of a larger cache to store and transfer.
Multi-stage builds benefit the most from a good cache
A multi-stage Dockerfile (a dependency-heavy build stage, a lightweight final stage that only copies the compiled artifact) naturally isolates what benefits most from caching: the build stage, usually the longest, becomes near-instant again if its own dependencies haven’t changed, even when the final stage changes on every commit.
Takeaway
Dockerfile instruction order determines the cache hit rate before backend choice even matters: isolating what rarely changes from what changes often limits cascading invalidation. A hosted CI runner, with no persistent machine, loses all local cache on every run, which makes an external backend like type=gha necessary to benefit at all. mode=max also keeps a multi-stage build’s intermediate layers, a detail that matters to get real value from a CI/CD pipeline building images on every deploy. The same caching discipline applies to a monorepo’s remote cache: two different mechanisms, the same principle of isolating what changes from what doesn’t.