self-host →

Cache keys, restore-keys, and cache scope in GitHub Actions

A deep dive into GitHub Actions caching: how to build a good cache key with hashFiles, how restore-keys fallback works, cache immutability, branch scope rules, and the 10 GB limit with LRU eviction.

In Using caching to speed up GitHub Actions workflows we covered the basics of actions/cache and why caching matters. This article is the deep-dive companion: it explains how cache keys are actually constructed, how restore-keys fallback works, why caches are immutable, and the branch-scope and eviction rules that decide whether you get a hit or a miss. Get these right and your cache stops being a coin flip.

All examples use actions/cache@v5 and ubuntu-latest.

Anatomy of a good cache key

A cache key is just a string, but the way you build that string determines how often you hit, how often you miss, and whether you ever restore the wrong cache. A good key is composed of three parts:

- name: Cache npm
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}

Read the key left to right:

  • ${{ runner.os }} — the operating system (and implicitly the architecture) of the runner. A cache built on Linux is useless to a Windows or macOS job, and native modules compiled on x64 will not work on arm64. Including runner.os keeps each platform’s cache separate. If you run on multiple architectures, add ${{ runner.arch }} too.
  • node22 — the language/toolchain version. Dependencies resolved or compiled against Node 22 are not guaranteed to be valid for Node 20. Bake the version into the key so an upgrade naturally creates a fresh cache instead of restoring a stale, incompatible one.
  • ${{ hashFiles('**/package-lock.json') }} — a hash of the lockfile. hashFiles returns a single SHA-256 derived from the contents of every matching file. When the lockfile changes (you added, removed, or bumped a dependency), the hash changes, so the key changes, so you get a new cache entry. When the lockfile is identical, the hash is identical and you get an exact hit.

Each component answers a different question: which platform, which toolchain, which exact set of dependencies. Drop any one of them and you open the door to restoring a cache that does not match the job actually running.

Cache immutability: keys are write-once

This is the single most important mental model for caching, and the one people most often get wrong.

Once a cache is written under a given key, it is never overwritten. If a later run tries to save under a key that already exists, GitHub keeps the original entry and silently skips the save (the step logs a message saying the cache already exists). There is no “update in place”.

That is by design, and it works hand in hand with hashFiles:

  • You bump a dependency, so package-lock.json changes.
  • The hash changes, so the key changes — say from Linux-node22-abc123 to Linux-node22-def456.
  • No cache exists for def456 yet, so the job builds fresh and saves a brand-new entry.

The old abc123 entry is untouched and still serves any run that still has the old lockfile. You are not “updating the cache”, you are accumulating immutable snapshots keyed by content. This is exactly what you want — but it also means your key must change whenever the cached contents should change. A key that never changes will pin you to the very first cache forever, even as your dependencies drift.

restore-keys: ordered prefix fallback

If the exact key is not found, a fresh feature branch or a lockfile bump would normally mean a complete cache miss and a from-scratch install. restore-keys softens that by letting you fall back to an older but related cache and build incrementally on top of it.

- name: Cache npm
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node22-
${{ runner.os }}-

Here is exactly what happens on a run:

  1. Exact-key lookup. The action first looks for a cache whose key matches key exactly. If found, that is a cache hit — it restores and is done. Because the contents match the lockfile hash, this is the ideal path.
  2. Prefix fallback. If there is no exact match, the action walks restore-keys top to bottom and, for each one, looks for any cache key that starts with that prefix. The first match wins. If multiple caches share a prefix, the most recently created one is chosen. This is a partial restore (a “cache hit” on the restore key but not the primary key).
  3. Total miss. If nothing matches any restore key, the step restores nothing and the path stays empty.

Two things about partial restores trip people up:

  • Ordering matters: most specific first. Put ${{ runner.os }}-node22- before ${{ runner.os }}- so you prefer a cache built for the same Node version before falling back to “any cache for this OS”. The action stops at the first matching prefix, so a too-broad key listed first will shadow a better one below it.
  • A partial restore still re-saves under the new exact key. When the job ends successfully and the exact key was not the one that got restored, actions/cache saves a new entry under the exact key. So a lockfile bump restores last run’s ~/.npm via a restore-key, npm ci only downloads the few packages that changed, and a fresh, fully-up-to-date cache is written under the new hash. Next run gets a clean exact hit. (Thanks to immutability, the restored-from entry is left intact.)

Always set both key and restore-keys for dependency caches. key gives you precise invalidation; restore-keys gives you a warm start when the key inevitably changes.

Single-step vs. split restore/save

actions/cache@v5 is the one-step form: it restores at the start of the job and automatically saves at the end (when the exact key was not already present). That is the right default for the common case.

Sometimes you want finer control over when the save happens. For that, actions/cache ships two sub-actions you can use separately:

  • actions/cache/restore@v5 — restore only.
  • actions/cache/save@v5 — save only.

A classic use case: only write the cache from the default branch, so pull requests read the warm cache but never spend storage budget polluting it with short-lived, branch-specific entries.

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Restore npm cache
id: cache
uses: actions/cache/restore@v5
with:
path: ~/.npm
key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node22-
- run: npm ci
- run: npm test
- name: Save npm cache
# Only save from the default branch, and only when there was no exact hit.
if: github.ref == 'refs/heads/main' && steps.cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: ~/.npm
key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}

Another common pattern is saving even when the job fails — useful for expensive build caches you do not want to rebuild from zero just because tests went red. Wrap the save in if: always():

- name: Save build cache
if: always()
uses: actions/cache/save@v5
with:
path: .build-cache
key: ${{ runner.os }}-build-${{ github.sha }}

Note the two outputs the restore step exposes: cache-hit is 'true' only on an exact key match, while cache-matched-key tells you which key (exact or restore-key) actually served the cache. Use cache-hit to skip work when the cache is perfectly fresh.

Built-in caching via setup actions

Before you reach for actions/cache at all, check whether your setup-* action can do it for you. The official language setups have a cache: input that wires up the correct path and a sensible key automatically — including hashing the lockfile.

- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm # also: yarn, pnpm
- uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: pip # also: pipenv, poetry

This is the simplest correct option for plain dependency caching and you should prefer it. Reach for a hand-written actions/cache step when you need something the setup action does not cover: caching build outputs, compiler caches, a non-standard directory, or fine-grained save control with the split actions above.

Cache scope: which branches can read which caches

A cache miss on a brand-new feature branch is almost always a scope issue, not a key issue. GitHub deliberately isolates caches between branches for security, so a run can only read a limited set of caches:

  • caches created on its own branch,
  • caches created on the repository’s default branch (usually main), and
  • for pull requests, caches created on the base branch (the branch the PR targets, including base branches in forks).

A run cannot restore caches from arbitrary sibling branches, child branches, or a different tag. If feature-b branches off feature-a, a run on feature-b can read caches from main, feature-a, and feature-b — but two unrelated feature branches never see each other’s caches.

This is exactly why a fresh feature branch gets cold-cache misses until the default branch’s cache is warm: until main has run and populated its cache, there is nothing in scope for your new branch to fall back to. The practical fix is to make sure your caching workflow runs on main (on push or on a schedule) so every new branch inherits a warm baseline.

One sharp edge worth knowing for pull requests: a cache created during a PR-triggered run is written for the merge ref (refs/pull/.../merge), so it has a narrow scope — only re-runs of that same pull request can restore it. It is not visible to the base branch or to other PRs targeting the same base. So PRs happily read the base/default cache, but caches they write mostly benefit only themselves. This reinforces the “save on the default branch” pattern from earlier.

Limits and eviction

GitHub gives each repository a default cache budget of 10 GB (enterprise, organization, or repo admins can raise it). Two things reclaim space:

  • Over-limit eviction is LRU by last access. When total cache size exceeds the limit, GitHub deletes caches “in order of last access date, from oldest to most recent” until you are back under budget. The key word is access, not creation: a frequently-restored old cache survives, while a large cache nobody has touched in a while is the first to go. Writing a huge new cache can therefore silently evict useful older ones.
  • Idle entries expire after 7 days. Any cache not accessed for more than 7 days is removed automatically, regardless of the limit.

The combination is fine for small dependency caches but quietly hostile to busy monorepos: a few large Docker-layer or build caches across many branches blow past 10 GB fast, and you end up thrashing — evicting the very caches you were about to reuse, which produces cache misses, which produce slow builds. You can audit what is taking up space from the repository’s Actions → Caches page or via the gh cache list CLI.

RunsOn tip: the 10 GB ceiling and slow restores are precisely what RunsOn Magic Cache removes — it transparently swaps the GitHub Actions cache backend for a fast, unlimited S3 cache inside your own AWS VPC (~5x faster, no eviction thrashing), and your actions/cache steps keep working unchanged.

Conclusion

Effective caching comes down to two ideas: build keys that change exactly when the cached contents should change (OS/arch + toolchain version + lockfile hash), and understand the rules around them — immutability means keys are write-once, restore-keys give you warm partial restores that re-save under the fresh key, branch scope decides what is even visible, and a 10 GB LRU budget decides what survives. Get those right and your cache becomes a reliable speed-up instead of a mystery. Next, we will look at caching Docker layers and uploading build artifacts, where these same scope and size pressures show up in an even bigger way.