Skip to content

Caching in GitHub Actions: Keys, Scope and Eviction

Lesson 8 of 11Intermediate5 min readGitHub Actions & CI/CD · Advanced ActionsVerified: actions/cache v6, August 2026

A cache turns a two-minute dependency install into ten seconds. A badly keyed cache turns it into ten seconds of restoring the wrong dependencies, which is worse than not caching at all — because the pipeline now tests a tree that does not match the manifest, and it does so silently.

Key design is the whole subject.

Several setup actions cache automatically, keyed correctly, with no extra step:

ActionCachesEnabled by
actions/setup-goModule and build cachesOn by default
actions/setup-nodeThe package manager’s storecache: npm / pnpm / yarn
actions/setup-pythonThe pip/pipenv/poetry cachecache: pip
actions/setup-java~/.m2, Gradle or sbt cachescache: maven / gradle / sbt
actions/setup-dotnetNot built in; cache manually

Adding an actions/cache step alongside one of these usually duplicates work and can conflict. Reach for actions/cache when the setup action does not cover it — .NET’s NuGet folder, a compiler cache, a downloaded toolchain, or build output.

- uses: actions/cache@v6
id: cache
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/packages.lock.json') }}
restore-keys: |
nuget-${{ runner.os }}-

The action does two things in one step: it restores at the point it appears in the job, and it saves at the end of the job — but only if the exact key produced a miss. A cache entry is immutable: once written under a key, that key’s content never changes.

steps.cache.outputs.cache-hit is 'true' only on an exact key match, not on a restore-keys fallback. Using it to skip the install step is therefore wrong when a partial restore happened — the usual pattern is to always run the install and let it be fast.

key: deps-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/package-lock.json') }}

What it doesBuilds a cache key from the files that determine the cached content, plus the platform it was built on.

Why we run itA key that does not change when the dependencies change means the first cache written is served forever. A key that does not include the platform means a Linux cache can be restored onto Windows.

Expected resultA new key — and a save — exactly when the manifest changes, and never otherwise.

Three components, each for a reason:

A name (deps-) distinguishes this cache from others in the repository and gives restore-keys something to prefix-match.

Platform identityrunner.os, and runner.arch on any cross-architecture matrix. Without arch, an ubuntu-24.04-arm leg cheerfully restores x86 binaries. Add anything else that changes the content: the language version on a version matrix, for instance.

A content hashhashFiles over the manifest and lock files. This is the part people omit, and omitting it is the bug: the cache is written once and never updated again.

key: deps-Linux-a1b2c3d4
restore-keys: |
deps-Linux-
deps-

On an exact miss, Actions tries each prefix in order and restores the most recent entry whose key starts with it. The result is a warm cache: most dependencies present, and the install fetches only the difference.

Order matters — most specific first. And note the interaction with matrix legs: deps- as a fallback on a cross-platform matrix will happily hand a Windows leg a Linux cache. Keep the fallbacks as specific as the correctness of the content requires.

This is the rule that explains most “why is it always a miss” questions.

A cache created by a workflow on branch feature-x is readable from:

  • feature-x itself
  • Child branches of feature-x

It is not readable from main, or from a sibling branch. A cache created on the default branch is readable from every branch in the repository.

The consequence in practice: the first run on a new branch misses unless a matching cache exists on the default branch. So a pipeline where caching only helps after the second push usually needs the cache to be written by a main build.

Each repository has a cache size limit. When it is exceeded, entries are evicted least recently used. Entries not accessed for a period are removed regardless.

Two consequences worth planning for:

A large cache evicts your other caches. A Docker layer cache with mode=max for a big image can consume most of the quota and push out the dependency caches that were doing the real work. If your npm cache starts missing after adding image caching, this is why.

Caching things that are cheap to recreate is a net loss. Compressing, uploading, downloading and decompressing has a cost; for a directory that takes ten seconds to rebuild, caching is slower.

Audit what you are storing:

Terminal window
gh cache list --limit 30 --sort size_in_bytes --order desc
gh cache delete <id>
  • Build output you will deploy. That is an artifact — artifacts are downloadable, retained on a policy you set, and meant to be consumed. Caches are an optimisation that may not be there.
  • Secrets, credentials, kubeconfigs. A cache is repository-wide storage readable by every workflow in scope.
  • Anything correctness depends on. A cache miss must only make the job slower, never make it fail or behave differently. If a missing cache breaks the build, the cache has become a dependency.
  • Data that changes within a single run. Caches are restored once, at the step.

For finer control — saving only on the default branch, or saving even when a later step failed — split the action:

- uses: actions/cache/restore@v6
id: restore
with:
path: ~/.cache/build
key: build-${{ runner.os }}-${{ github.sha }}
restore-keys: build-${{ runner.os }}-
{/* …build steps… */}
- uses: actions/cache/save@v6
if: always() && github.ref == 'refs/heads/main'
with:
path: ~/.cache/build
key: build-${{ runner.os }}-${{ github.sha }}

The combined action only saves on success. save with if: always() preserves a compiler cache from a failed build, which is exactly when you want the next attempt to be fast.

Writing the cache only from main is also the deliberate way to build the shared warm cache that feature branches restore from.

  1. Add a cache with a key containing no hash. Run twice, then change the manifest and run again. Confirm the stale cache is still restored — this is the bug in its natural habitat.

  2. Add hashFiles to the key. Confirm the manifest change now produces a miss and a save.

  3. Misspell the glob so hashFiles matches nothing. Read the key in the step log and note the empty segment.

  4. Create a cache on a feature branch. Open a second branch from main and confirm the miss. Then merge and confirm the new branch hits.

  5. Run gh cache list --sort size_in_bytes --order desc and check whether one entry dominates your quota.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.