Throwing away the runner after every job is the right call, and it costs you the build cache unless the cache lives somewhere else. On GitHub Actions it already does, which means the real work is writing keys that hit instead of keys that always miss. This guide covers restore-keys and how partial matching actually resolves, the hidden part of a cache key that nobody sets, why a cache saved on a feature branch is invisible to main, and what to do when a bad cache entry starts poisoning every run.
Ephemeral runners solve the problem of builds inheriting state. They create a smaller one, which is that a dependency install which used to take twenty seconds now takes three minutes, every time, on every job.
The cache is the answer, and on GitHub Actions it never lived on the runner in the first place. It lives in a cache service attached to the repository, which is why the same cache works across machines that have never met. Getting value out of it is mostly about keys.
One prerequisite if the runners are yours
The cache backend was rewritten and actions/cache now talks to the version 2 APIs. The action's own README is explicit about the consequence for self-hosted fleets, saying that if you are managing your own GitHub runners, you must update your runner version to 2.231.0 or newer to ensure compatibility with the new cache service. Bake that into the runner image and check it, because the failure looks like a cache that simply never hits.
Keys that hit
A cache key is any string you can build from contexts, functions and literals, up to 512 characters. The one that matters most is hashFiles, because it changes exactly when your dependencies change.
# .github/workflows/build.yml
- name: Cache npm
id: npm-cache
uses: actions/cache@v4
with:
path: ~/.npm
key: v1-${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
v1-${{ runner.os }}-npm-
Without restore-keys, a single dependency bump means a full cold install. With them, a lockfile change misses the exact key and then falls back to the most recently created cache whose key starts with the same prefix, so you install the difference rather than everything.
The resolution order is worth knowing precisely. The action looks for an exact match on key, then works through restore-keys in the order you wrote them, treating each as a prefix. Where several caches match one prefix, the most recently created one wins. In a pull request it searches the current branch first, then the base branch, then the default branch.
The cache-hit output is true only on an exact match of the primary key. It is false when a restore key matched, which is exactly the case where you still need to run the install step. Gating your install on cache-hit therefore does the right thing.
- name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci
The part of the key you did not write
There is a second component that is not in your YAML. Every cache carries a version, which is a hash of the compression tool used and the path you asked to cache. Two caches with different versions are treated as different caches even when the key string is identical. Change path from ~/.npm to ~/.npm/_cacache and every existing entry stops matching, silently. A cache created on a Windows runner will not restore on Ubuntu for the same reason.
When a key that should obviously hit does not, this is usually why, and the list-caches REST API will show you the version so you can compare.
Scope, and why main never sees your branch
Caches are isolated by branch, and the isolation runs one way. A workflow can restore caches created on its own branch, on the base branch of a pull request, and on the default branch. It cannot restore a cache created on a child branch, a sibling branch or a different tag. A cache created on feature-b is not accessible to a run on main.
That has a practical shape. Populate the cache on your default branch, from a scheduled or push-triggered job, and every feature branch inherits a warm one on its first run. Rely only on branches to populate their own and every new branch starts cold.
An entry cannot be edited
The documentation puts it plainly, saying you cannot change the contents of an existing cache, and that instead you create a new cache with a new key. Once a key is written, that content is what the key means until the entry is evicted.
This is the whole reason a bad cache is dangerous. If a build wrote a corrupt module tree, or a half-downloaded archive, or an artefact built from a dependency that has since been yanked, the entry sits there being restored into every subsequent run, and no amount of re-running fixes it.
Getting out of a poisoned cache
Put a version prefix in every key. The v1- at the front of the example above exists for one purpose, which is that changing it to v2- invalidates every entry in that family in one commit, with no API calls and no permissions.
When you want the entries actually gone, delete them.
gh cache list --ref refs/heads/main --limit 100
gh cache delete "v1-Linux-npm-a1b2c3d4"
Deleting through the API from a workflow needs permissions: actions: write, and through the web interface it needs write access to the repository.
The structural fix is to stop saving caches from builds that failed. Split the action in two, restore at the start, and save at the end only when the job got that far.
- name: Restore npm cache
id: npm-restore
uses: actions/cache/restore@v4
with:
path: ~/.npm
key: v1-${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
v1-${{ runner.os }}-npm-
- run: npm ci
- run: npm test
- name: Save npm cache
if: success()
uses: actions/cache/save@v4
with:
path: ~/.npm
key: ${{ steps.npm-restore.outputs.cache-primary-key }}
Docker layers
Image layers use a different backend built on the same service.
docker buildx build --push -t registry.example.com/app:latest \
--cache-from type=gha \
--cache-to type=gha,mode=max,scope=app-main .
mode=max exports every layer rather than only the final ones, and scope keeps separate builds from overwriting each other, defaulting to buildkit if you leave it out. Using docker/build-push-action populates the service URL and token for you.
Live within the storage limit
A repository gets 10 GB of cache by default, which owners and administrators can raise. When you cross the limit, GitHub keeps the new entry and evicts existing ones by last access date, oldest first. Anything not accessed in over seven days is removed regardless.
Docker layer caches are large enough to evict everything else, which is the usual reason a dependency cache that worked last month stops hitting. Scope the image cache, and check what is actually stored with gh cache list before assuming the keys are wrong.
Our CI/CD pipeline setup work covers caching and parallelism as part of the pipeline rather than as an afterthought, and it pairs with DevOps as a service when you want someone to keep it tuned as the project grows.
Frequently asked questions
What runner version do self-hosted GitHub runners need for actions/cache?
Self-hosted GitHub runners need version 2.231.0 or newer, because actions/cache now talks to the version 2 APIs of a rewritten cache backend. The action's README says anyone managing their own runners must update to that version to stay compatible with the new cache service. Bake the version into the runner image and check it, since the failure looks like a cache that simply never hits.
How do restore-keys work in the GitHub Actions cache?
When the primary key misses, actions/cache works through restore-keys in the order written, treating each as a prefix, and the most recently created cache that matches wins. A lockfile change then restores an older cache and only the difference gets installed. In a pull request the search covers the current branch, then the base branch, then the default branch. A restore-key match sets cache-hit to false, since cache-hit is true only on an exact primary key match.
Why does a GitHub Actions cache stop matching when the key has not changed?
Every GitHub Actions cache carries a version, a hash of the compression tool and the cached path, and caches with different versions count as different even when the key strings are identical. Changing path from ~/.npm to ~/.npm/_cacache silently stops every existing entry from matching. A cache created on a Windows runner will not restore on Ubuntu for the same reason. The list-caches REST API shows the version for comparison.
Can a workflow on main restore a GitHub Actions cache saved on a feature branch?
No. Cache isolation runs one way, so a workflow can restore caches from its own branch, the base branch of a pull request and the default branch. A cache created on a child branch, a sibling branch or a different tag stays out of reach, which is why a cache saved on feature-b is invisible to main. Populate the cache on the default branch from a scheduled or push-triggered job, and every feature branch gets a warm cache on its first run.
How do I fix a poisoned GitHub Actions cache?
Change the version prefix in the key, for example from v1- to v2-, which invalidates every entry in that family in one commit with no API calls and no permissions. The contents of an existing cache cannot be edited, so a bad entry keeps being restored until it is evicted or deleted. To delete entries, list them with gh cache list --ref refs/heads/main --limit 100 and remove one with gh cache delete and its key. Deleting through the API from a workflow needs write permission for actions.
How do I stop failed builds from saving to the GitHub Actions cache?
Split the cache step into actions/cache/restore@v4 at the start of the job and actions/cache/save@v4 at the end, with the save step running only on the success() condition. The save step takes its key from the restore step's cache-primary-key output. A job that fails partway never writes a cache, so a corrupt module tree or a half-downloaded archive cannot reach later runs.
How do I cache Docker layers in GitHub Actions?
Pass --cache-from type=gha and --cache-to type=gha,mode=max,scope=app-main to docker buildx build, which stores layers in a backend built on the same cache service. The mode=max setting exports every layer instead of only the final ones, and scope keeps separate builds from overwriting each other, defaulting to buildkit when left out. Using docker/build-push-action fills in the service URL and token automatically.
What is the GitHub Actions cache size limit per repository?
A repository gets 10 GB of cache by default, and owners and administrators can raise it. Past the limit, GitHub keeps the new entry and evicts existing ones by last access date, oldest first, and anything not accessed in over seven days is removed regardless. Docker layer caches are large enough to evict everything else, which is the usual reason a dependency cache that worked last month stops hitting.
Read how we handle it in Infrastructure Setup.
Related Articles
How to Run Ephemeral CI Runners on Your Own Hardware
A build that passes because of something left behind by the previous build is not a passing build, it is a coincidence. Ephemeral runners remove that class of problem by giving every job a machine that has never run anything else. GitHub supports this directly through single-use runner registration and just-in-time configuration, so the runner deregisters itself after one job and your automation disposes of the host. This guide covers both approaches, the Kubernetes version, and the one situation where self-hosted runners are the wrong answer.
CloudHow to Run Multi AZ So It Actually Survives an AZ Failure
Most AWS accounts are multi AZ on paper already, and then a zone has a bad day and the site goes down anyway. Spreading a deployment across zones and keeping it serving when one disappears are two different properties. This covers what the managed services really do during a zone failure, including which failovers reset every open connection and how long each one takes, the single points that quietly survive a multi AZ design, and the commands to rehearse all of it on purpose.
SecurityEleven npm Packages Compromised in a 53 Minute Attack That Steals Every Credential Your Build Host Can Reach
On August 4, 2026 a worm pushed malicious versions of eleven npm caching packages inside a 53 minute window, harvesting npm tokens, GitHub PATs, AWS credentials, Kubernetes service account tokens and SSH keys. The headline was keyv and its 604 million monthly downloads, but keyv was the safest package on the list: its malicious release was a major version bump that no caret range accepts. The other ten were patch bumps, silently eligible for every dependency range in the ecosystem. That distinction, not the download count, decided who got hit. This is a practical guide to the defenses that actually change the outcome: what your semver range really grants, why npm install and npm ci are not interchangeable, when to disable install scripts and what breaks when you do, and how to check a tree you already have.