GitHub Actions Dependency Caching: A Worked Configuration and the Mistakes That Break It
actions/cache speeds up GitHub Actions by restoring your package manager's download store between runs — but only if your key includes a lockfile hash.
17 Jul 2025, 19:56 UTC

If your GitHub Actions build spends two minutes downloading dependencies that changed last month, the fix is actions/cache: it stores a directory (your package manager's download store) between runs, keyed by a string you define. On a cache hit, the directory is restored before your build step starts, and dependency install time typically drops from minutes to seconds. The catch is that the cache is only as good as your key — a bad key silently serves stale dependencies or never hits at all.
How the cache actually works
Three rules govern everything:
- Key-based restore. At the start of the step, the action looks for a cache entry matching your
keyexactly. If found, it extracts the archive into yourpath. - Prefix fallback. If the exact key misses, the action tries each entry in
restore-keysas a prefix and restores the most recent match. You get a partial, still-useful cache. - Save on success, once. At the end of the job, if the job succeeded and no cache already exists for the exact key, the directory is archived and uploaded. A cache entry is immutable — it is never updated in place.
That immutability is the important design consequence: you don't "refresh" a cache, you change the key so a new one gets written. This is why the standard pattern hashes your lockfile into the key.
A worked configuration: Maven on Ubuntu
This workflow caches the Maven local repository (~/.m2/repository), keyed on the OS plus a hash of every pom.xml:
name: build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Cache Maven repository
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Build
run: mvn -B verifyBehavior in practice:
- First run on a branch: exact key misses, the
ubuntu-latest-maven-prefix may match a cache from the default branch, dependencies partially restore, and at job end a new entry is saved under the full key. - Subsequent runs, no dependency changes: exact key hits, full restore, no new save (the key already exists).
- After editing a pom.xml: the hash changes, the exact key misses, the prefix restores the old cache as a starting point, Maven downloads only the new artifacts, and a fresh cache is saved under the new key.
The same pattern applies to other ecosystems — cache ~/.npm keyed on package-lock.json, ~/.gradle/caches on *.gradle and gradle-wrapper.properties, or pip's cache directory on requirements.txt. Note that setup-java, setup-node, and similar actions have a built-in cache: option that wraps this for you; use the explicit action when you need control over paths or keys.
Branch scoping: why the first PR build is slow
Caches are scoped by branch. A workflow can restore caches created on its own branch or on the repository's default branch, but not from arbitrary sibling branches. Consequences:
- The first run of a brand-new PR branch falls back to the default branch's cache via
restore-keys— usually good enough. - Two feature branches with divergent lockfiles each maintain their own cache entries; neither pollutes the other.
- If your default branch rarely builds, its cache may be old, making every new PR's first run slow. A scheduled nightly build on the default branch keeps the base cache warm.
Limits you cannot design around
- 10 GB per repository. GitHub evicts least-recently-used entries when you exceed it. Large monorepos with many branches can churn constantly.
- 7-day inactivity removal. Any cache not accessed for a week is deleted. Caching is an optimization, never a source of truth — your build must work correctly on a cold cache.
- Save requires job success. A failing build never updates the cache, so a broken dependency change can leave you on a stale cache until the build goes green.
These limits and the action's major version change over time; check the actions/cache releases page and current GitHub documentation before relying on numbers.
The mistakes that cause stale or missed caches
1. A static key
key: maven-cache means the first cache written lives until eviction. Dependency updates are silently ignored because the exact key always hits. Always include a lockfile hash.
2. Caching build outputs alongside dependencies
If you cache ~/.m2 and your target/ or node_modules with compiled output, stale artifacts leak between runs and you get "works in CI, broken locally" bugs. Cache only the package manager's download store — the thing that is purely a function of the lockfile. (node_modules is a gray area: it works for some teams, but caching npm's cache directory and running npm ci is more predictable.)
3. No restore-keys fallback
Without restore-keys, every lockfile change means a completely cold start. A prefix fallback restores the previous cache so only the delta downloads.
4. Overly broad hash inputs
Hashing **/* or including files that change every commit (version files, generated sources) busts the cache on every push and you pay save/restore overhead for zero benefit. Hash only the lockfiles.
Verifying it works
- Run the workflow twice on the same commit. The second run's cache step should log
Cache restored from key: ...and the dependency install step should be measurably faster. - Make a trivial lockfile change (bump a patch version), push, and confirm the log shows a miss on the exact key, a restore from the prefix, and a
Cache saved with keymessage at job end. - Open Actions → Caches in the repository (under Management in the left sidebar) to see entries, sizes, and last-access times. This is also where you delete a poisoned cache manually — the standard recovery when a bad artifact gets saved.
If a cache entry ever contains something corrupted or wrong, deleting it from that UI (or via gh cache delete) is safe: the next run simply rebuilds it from scratch.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.