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.
Check whether you need it at all
Section titled “Check whether you need it at all”Several setup actions cache automatically, keyed correctly, with no extra step:
| Action | Caches | Enabled by |
|---|---|---|
actions/setup-go | Module and build caches | On by default |
actions/setup-node | The package manager’s store | cache: npm / pnpm / yarn |
actions/setup-python | The pip/pipenv/poetry cache | cache: pip |
actions/setup-java | ~/.m2, Gradle or sbt caches | cache: maven / gradle / sbt |
actions/setup-dotnet | — | Not 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.
Anatomy of a cache step
Section titled “Anatomy of a cache step”- 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 design rules
Section titled “Key design rules”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 identity — runner.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 hash — hashFiles 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.
restore-keys
Section titled “restore-keys”key: deps-Linux-a1b2c3d4restore-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.
Scope: which caches a run can see
Section titled “Scope: which caches a run can see”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-xitself- 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.
Quotas and eviction
Section titled “Quotas and eviction”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:
gh cache list --limit 30 --sort size_in_bytes --order descgh cache delete <id>What not to cache
Section titled “What not to cache”- 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.
Separate restore and save
Section titled “Separate restore and save”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.
Exercise
Section titled “Exercise”-
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.
-
Add
hashFilesto the key. Confirm the manifest change now produces a miss and a save. -
Misspell the glob so
hashFilesmatches nothing. Read the key in the step log and note the empty segment. -
Create a cache on a feature branch. Open a second branch from
mainand confirm the miss. Then merge and confirm the new branch hits. -
Run
gh cache list --sort size_in_bytes --order descand check whether one entry dominates your quota.