Skip to main content
Server & DevOpsAugust 25, 20266 min read

How to Keep a Build Cache That Survives Ephemeral Runners

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.

Talk to the engineer who will own your stack.

No account managers, no offshore handoff. Senior DevOps, direct. Tell us what you are dealing with and you get a straight answer.