CI Pipeline Caching That Actually Pays Off
Most CI caches either miss constantly or restore stale junk. The cache-key discipline, scope boundaries, and measurements that turned our pipeline cache from theatre into real minutes saved.
Key takeaways
- A CI cache pays off only when it hits often and never restores stale content.
- Key it on a hash of the lockfile, add a prefix restore-keys fallback for partial hits, cache dependency downloads rather than build outputs, and measure the hit rate.
- On GitHub Actions, cache-hit is true only on an exact key match.
On this page
CI caching pays off when the key is a hash of the lockfile, a restore-keys prefix catches partial hits, and you cache dependency downloads rather than build outputs. Then measure the exact-hit rate. A cache that misses every run is pure upload and download overhead, and one that restores stale artifacts is worse than no cache.
Verified against
actions/cache v6.1.0 (latest release as of October 2026), Docker Buildx registry cache backend, against the GitHub Actions dependency caching reference, the actions/cache release notes and docs.docker.com. Checked October 2026.
Caching is the first optimization everyone reaches for when CI gets slow, and the one most often done wrong. This is what it takes to get a pipeline cache from "configured" to "actually saving minutes."
Measure first: is the cache even hitting?
Before tuning anything, instrument the hit rate. Most CI systems report cache restore status; surface it.
- name: Restore deps
id: cache
uses: actions/cache@v6
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Report cache status
run: echo "cache-hit=${{ steps.cache.outputs.cache-hit }}"
Read that output carefully. GitHub's docs define cache-hit as true only when the key matches exactly; a restore-keys partial match leaves it false, so count exact hits and partial hits separately. The failure we see most often is a key that quietly includes something volatile, like a timestamp added during debugging. Every run writes a new entry, nothing ever matches, and the cache is pure overhead.
The cache key is the whole game
A cache key has one job: change exactly when the cached content should change, and not before. Two failure modes:
- Too volatile (timestamp, commit SHA, run number): never hits.
- Too stable (just the OS): restores stale content; you cache yesterday's dependencies.
The right key is a hash of the inputs that determine the output. For dependencies, that's the lockfile, not package.json, because the lockfile is what pins exact versions.
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: the partial-hit fallback
When the exact key misses (lockfile changed), restore-keys lets you fall back to the most recent prefix match. You get last build's node_modules, then npm ci only reconciles the delta instead of downloading everything.
restore-keys: |
npm-${{ runner.os }}-
On a one-package bump, that turns a cold install into a warm one that only fetches the delta. GitHub checks restore keys in order, matches them as prefixes, takes the most recently created match, and looks on the current branch before the default branch. The exact-key write keeps future identical runs at a full hit; the prefix fallback keeps changed runs from going fully cold.
Scope the cache to what's actually reusable
Don't cache build outputs keyed on source unless the build is deterministic. We cached a compiled bundle keyed on the lockfile, and it served a stale bundle because the source had changed but dependencies hadn't. Cache the inputs to an expensive step (downloaded packages, base layers), not the step's product, unless you key on the full input set.
Good caching candidates:
- Package manager download caches (
~/.npm,~/.cache/pip,~/.cargo) - Compiler caches keyed on source hashes (
sccache,ccache) - Docker layer caches via
--cache-from
Bad candidates:
node_modulesitself across major version bumps (platform-specific binaries)- Anything keyed loosely enough to serve stale results
Docker layer caching is its own discipline
For image builds, ordering the Dockerfile so that rarely-changing layers come first is more impactful than any external cache. Copy the lockfile and install before copying source:
COPY package-lock.json package.json ./
RUN npm ci
COPY . .
RUN npm run build
Now a source-only change reuses the npm ci layer. Combined with a registry-backed cache, cold runners still get warm layers. The ref for the cache must differ from the image tag you push:
docker buildx build --push -t ghcr.io/acme/api:latest \
--cache-to type=registry,ref=ghcr.io/acme/api:build-cache,mode=max \
--cache-from type=registry,ref=ghcr.io/acme/api:build-cache .
mode=max exports intermediate layers from every stage of a multi-stage build, not only the final stage. For the cache-miss cases this setup still hits, see fixing Docker build cache misses.
When a cache is net-negative
Every cache costs a download on restore and an upload on save, and that overhead is paid on every run whether it hits or not. The arithmetic is simple: a cache is worth keeping only when hit rate times the time it saves exceeds that fixed cost. A cache that saves a minute but hits a quarter of the time can lose to one that saves twenty seconds and hits nearly always. When we fixed the key, added restore-keys and reordered the Dockerfile, dependency install became a fraction of its old time; the caches that stayed at low hit rates afterwards were the ones we removed.
Storage limits also shape this. As of October 2026, GitHub gives each repository 10 GB of cache by default (admins can raise it, and usage above the default is billed), evicts entries unused for more than 7 days, and deletes the oldest-accessed entries first once the repository is full. A volatile key doesn't just miss; it floods the quota and evicts the caches that were working.
Where this breaks
Cache lookups are branch-aware: the action checks the current branch first, then the default branch, so the first run on a new branch starts from the default branch's entry at best. Self-hosted runners need a runner version that matches the action: actions/cache v5 moved to the Node.js 24 runtime and requires Actions Runner 2.327.1 or later. And if your build is not deterministic, no key design saves you from caching its outputs. For the rest of the workflow, the GitHub Actions dependency caching recipes cover per-language paths.
Questions people ask
Why is cache-hit false even though my cache restored?
Because cache-hit is true only on an exact match of key. When the restore came from a restore-keys prefix, GitHub restores the most recent matching entry but reports cache-hit as false. That is useful: gate expensive steps like npm ci on it only if a partial restore is not enough, and log both outcomes so you can see exact and partial hit rates separately.
What should the cache key be for npm dependencies?
Hash the lockfile, not package.json: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}. The lockfile pins exact versions, so the key changes exactly when the dependency set changes. Include the OS because packages with native binaries differ per platform. Never include timestamps, run numbers or commit SHAs, which guarantee a miss on every run.
How much cache storage does GitHub Actions give per repository?
As of October 2026, 10 GB per repository by default. Enterprise, organization or repository admins can raise the limit, with usage above the default billed, and user-owned repositories can go up to 10 TB. Entries not accessed for more than 7 days are evicted, and when the repository hits its limit the oldest-accessed caches are deleted first.
The rule we landed on
Cache the expensive, deterministic inputs. Key on the exact thing that invalidates them. Add a prefix fallback for partial reuse. Then measure the hit rate. A cache you don't measure is a cache you can't trust, and an untrusted cache is one more thing making your builds slow and flaky at the same time.
Sources
Cite this article
Kiril Urbonas (2026). CI Pipeline Caching That Actually Pays Off. DevOpsNess. Updated 2026-10-31. https://www.devopsness.com/blog/ci-pipeline-caching-that-pays-off
[CI Pipeline Caching That Actually Pays Off](https://www.devopsness.com/blog/ci-pipeline-caching-that-pays-off) (Kiril Urbonas, DevOpsNess, updated 2026-10-31)
More from DevOps
Explore more articles in this category
Gheist: Stolen GitHub Tokens Are Dumping Actions Secrets Past Log Masking. Hunt for ci-sync Branches
An automated tool is using stolen GitHub tokens to plant ci-sync branches whose workflow prints every secret base32-encoded, grabs the log and deletes the run. How to hunt for it across an org, what to rotate, and the controls that stop the next one.
Argo CD CVE-2026-113708: Read-Only Users Can Run Code on the Repo-Server. Upgrade to 3.4.10+
Three CVSS 9.9 Argo CD repo-server bugs can be reached by anyone with Application get, the permission most teams hand to every viewer. Fixed versions, the v2 and 3.3 support cliff, and the RBAC and config checks to run today.
CVE-2026-111497 "Broccoli2Shell": One Brotli Bug in Node.js, Envoy and Istio. Patch Node First
An out-of-bounds write in Google's Brotli decoder, now on CISA's KEV list, reaches Node.js through zlib and Envoy and Istio through the Brotli decompressor filter. Fixed versions, cluster-wide checks and what to block until you patch.
You might have missed
Evergreen posts worth revisiting.