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.
Or read how we handle it in CI/CD Pipeline 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.