actions/cache failures cluster into a handful of shapes: a restore that dies with Permission denied, a cache that is mysteriously "not found" on one branch but exists on another, a save that reports a conflict, and workflows on old versions that stopped working entirely. Each one has a specific cause and a specific fix - none of them is "retry the job". Here is the field guide.
Permission denied during restore
A cache restore is a tar extraction into your paths. It fails with EACCES or Permission denied when the runner user cannot write over what is already there - and on a hosted runner that almost always means files owned by root. The usual culprits:
- A container step wrote into the workspace. Steps running in Docker containers execute as the container's user - often root - so files they create (a
node_modules, a build output) are root-owned on the host. A later restore into the same path cannot overwrite them. - A
sudoinstall step created the cached directory. Caching a path like/usr/local/lib/somethingthat was populated withsudofails the same way in reverse: the extraction runs unprivileged and cannot write there.
Three fixes, in order of preference:
# 1) keep cached paths inside user-writable locations
path: ~/.npm # package-manager caches, not system dirs
# 2) fix ownership after any root-writing step
- run: sudo chown -R "$(id -u):$(id -g)" node_modules dist
# 3) run the container step as the runner's uid
container:
image: my-image
options: --user 1001The first is the durable one: cache package-manager cache directories (~/.npm, ~/.gradle/caches, ~/.cargo) rather than installed system paths or project directories a container may have touched.
"Cache not found" on a branch where you swear it exists
Cache entries are branch-scoped. A job can restore caches created on its own branch, the base branch of its pull request, or the repository's default branch - and nothing else. Two sibling feature branches never share entries, and a cache saved in a pull-request run is not visible to main. The pattern that follows from the rules: let the default branch populate shared caches (dependency caches especially), so every new branch starts warm from main's entries. If your misses spike on new branches, check whether anything on the default branch is actually saving with the same key prefix, and keep restore-keys as orderly fallbacks:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-Also remember the eviction rules when a cache vanishes overnight: entries untouched for 7 days are removed, and once a repository exceeds its total cache quota (10 GB by default) the oldest entries are evicted until it fits. A busy monorepo with fat per-branch entries can churn through that quota in a day, silently deleting the caches you were counting on.
Saves that fail: immutable entries and key conflicts
Cache entries are immutable. When two parallel jobs race to save the same key, one wins and the other logs a reserve/already-exists failure; when a completed key already exists from an earlier run, the save is skipped. That is harmless noise if the entries are equivalent - and a design smell if you were relying on the same key being overwritten with fresher content. A key should uniquely describe its contents:
- Derive keys from content hashes (
hashFilesover lockfiles or source globs), never a static string you expect to mutate. - For accumulating caches, put something run-specific in the key and rely on
restore-keysto find the nearest previous entry.
Old versions stop working
GitHub shut down the legacy cache service backend in early 2025. Workflows pinned to old tags of actions/cache (and old toolchain actions that cache internally) fail against the new service with errors that look like infrastructure problems rather than what they are: a version pin that aged out. The fix is mechanical - bump to the current major listed in the actions/cache README everywhere, including composite actions and reusable workflows that wrap it. If a workflow that "never changed" started failing on save or restore, check the pinned version before debugging anything else.
The failure mode with no error at all
The most expensive actions/cache problem produces green checkmarks: the cache restores, the log says hit, and the job is still slow - because a restore-then-verify cycle only saves the download, not the work. actions/cache caches files; it does not know which build steps could have been skipped. Compilation, tests, and lint re-run from scratch on every runner no matter how warm the dependency cache is. That second class of caching - skipping the work itself, keyed on the inputs that produced it - is a task-level remote cache, and it composes with actions/cache rather than replacing it: dependencies restore from the Actions cache, task outputs restore from the build cache. Where the line sits and how the two combine in a real workflow is the subject of our GitHub Actions cache guide.
Quick reference
Permission denied- root-owned files from container orsudosteps; cache user-writable paths, orchownbefore restore/save.- "Cache not found" - branch scoping: only same branch, PR base, or default branch; warm shared caches from the default branch.
- Vanishing entries - 7-day unused eviction and the repository quota; fat entries churn the quota fastest.
- Failed saves - entries are immutable; make keys content-addressed with
hashFiles. - Sudden failures on old workflows - the legacy service shutdown; bump the action version.
- Green but slow - dependency caching cannot skip work; that is a job for a task-level remote cache.