Skip to main content
CI Pipeline Caching That Actually Pays Off

CI Pipeline Caching That Actually Pays Off

KU
· Founder & Chief Editor
• 7 min read•Updated •49 views

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.

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.

.github/workflows/ci.yml
- 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.

YAML
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.

YAML
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_modules itself 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:

Dockerfile
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:

Terminal
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

React
Share this post
Cite this article
Plain text

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

Markdown

[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)

KU

Kiril Urbonas

Founder & Chief Editor

590 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.