# CI Pipeline Caching That Actually Pays Off

URL: https://www.devopsness.com/blog/ci-pipeline-caching-that-pays-off
Author: Kiril Urbonas
Published: 2026-06-19
Updated: 2026-10-31
Categories: DevOps
Tags: CI/CD, GitHub Actions, Caching, Performance, DevOps

Summary: 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.

---
# CI Pipeline Caching That Actually Pays Off

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

```yaml
# .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
# 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:

```
$ 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](/blog/fix-docker-build-cache).

## 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](/blog/github-actions-cache-dependencies) 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

- [GitHub Docs: Dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
- [actions/cache releases](https://github.com/actions/cache/releases)
- [Docker Docs: Registry cache backend](https://docs.docker.com/build/cache/backends/registry/)

