Cache-Key Strategies for Deterministic CI Builds

You want CI caches that hit every time the inputs are unchanged and miss the instant they change β€” no stale artifacts, no permanent cache misses β€” which comes down to composing the key correctly, the core skill behind securing and invalidating build caches.

When to use this pattern

  • Your cache either serves stale output or almost never hits, and you need a principled key.
  • You cache multiple things (dependencies, build output, Docker layers) and want a consistent recipe.
  • You need a way to force a clean rebuild without editing every workflow.

Prerequisites

Complete working example

# .github/workflows/cache-keys.yml β€” one recipe applied to three cache types
name: CI
on: [pull_request]
env:
  CACHE_EPOCH: v4          # bump to force a global clean rebuild
  NODE_VERSION: "20"
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # 1. Dependency cache β€” keyed on the LOCKFILE (exact resolved tree).
      - uses: actions/cache@v4
        with:
          path: ~/.local/share/pnpm/store
          key: ${{ env.CACHE_EPOCH }}-deps-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('pnpm-lock.yaml') }}
          restore-keys: |
            ${{ env.CACHE_EPOCH }}-deps-${{ runner.os }}-node${{ env.NODE_VERSION }}-

      # 2. Build-output cache β€” keyed on lockfile + build config + SOURCE hash.
      - uses: actions/cache@v4
        with:
          path: .turbo
          key: ${{ env.CACHE_EPOCH }}-build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json', 'tsconfig.json') }}-${{ hashFiles('src/**', 'packages/**/src/**') }}
          restore-keys: |
            ${{ env.CACHE_EPOCH }}-build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'turbo.json', 'tsconfig.json') }}-
            ${{ env.CACHE_EPOCH }}-build-${{ runner.os }}-

      - run: corepack enable && pnpm install --frozen-lockfile
      - run: pnpm build

For Docker, the β€œkey” is the instruction order and the registry cache ref rather than a hash string:

# Docker's cache key is per-layer: copy the lockfile and install BEFORE copying source,
# so a source-only change does not bust the dependency layer.
COPY pnpm-lock.yaml package.json ./
RUN pnpm install --frozen-lockfile     # cached unless the lockfile changes
COPY . .
RUN pnpm build
What Belongs in a Cache Key, and Why The lockfile hash, runner operating system and runtime version must be in the key because omitting any of them allows a restore built from different inputs. The branch name and a manual version prefix are optional: including them reduces reuse but never causes a wrong restore. MANDATORY β€” omitting these permits a wrong restore lockfile content hash runner OS runtime major.minor version OPTIONAL β€” these only cost reuse branch name manual v1 / v2 prefix config file hash Never put a run id, a timestamp or a commit SHA in a dependency-cache key: the key then matches exactly once, ever.

Step-by-step walkthrough

Dependency cache β€” hash the lockfile. hashFiles('pnpm-lock.yaml') changes exactly when the resolved dependency tree changes. Hashing package.json instead would miss transitive updates that alter the lockfile but not the manifest. The node${{ NODE_VERSION }} segment ensures a Node upgrade busts the cache, since native modules differ across versions.

Build-output cache β€” hash inputs and source. A content-addressed build cache (Turborepo’s .turbo) is only valid for the exact source and config that produced it, so the key adds hashFiles('src/**', …). The layered restore-keys allow partial reuse: an exact source match hits first; failing that, the same config prefix lets the tool reuse task-level cache for unchanged packages; failing that, a broad prefix still warms the directory.

Restore-keys ordering. Restore-keys are tried top-to-bottom, most-specific first. Each is a prefix match. Keep the loosest one strict enough that it never matches across incompatible configs β€” for example, never fall back across a different Node version.

Manual epoch. CACHE_EPOCH prefixes every key. When something outside the hashed inputs changes β€” a base image, a native system library, a suspected corrupt entry β€” bumping the epoch once misses every cache and forces a clean rebuild, without editing individual keys.

Docker layer ordering. Docker keys each layer on the instruction plus the files it touches. Copying the lockfile and installing before copying source means a source-only change reuses the dependency layer β€” the Docker layer caching principle.

Key Specificity: The Band Worth Aiming For A key with too few components gives a high hit rate and a real risk of restoring output built from different inputs. A key with too many gives near-zero hit rate and no risk. Between them sits a band where every input that affects output is present and nothing else is. too loose high hit rate, wrong restores possible the band to aim for every output-affecting input, nothing else too tight safe, and effectively no cache at all fewer key components more key components When unsure, move right: an unnecessary miss costs minutes, an unsound hit costs a debugging session.

Verification

# Dependency cache: change one dependency, confirm a miss; revert, confirm a hit.
# Look in the "Cache" step log for:
#   "Cache restored from key: v4-deps-Linux-node20-<hash>"   β†’ hit
#   "Cache not found for input keys: ..."                    β†’ miss

# Prove determinism: two runs on the same commit must produce identical keys.
git stash; gh workflow run ci.yml; # note the key hash
gh workflow run ci.yml;            # same commit β†’ identical key β†’ hit

Expected: identical inputs yield identical keys and a hit; any output-affecting change yields a new key and a miss.

The Manual Version Prefix Is Your Escape Hatch When a key is found to be missing an input, existing entries are already unsound and cannot be trusted. Bumping a version prefix in the key invalidates every entry in one commit, which is faster and more complete than attempting to purge selectively. key flaw discovered every stored entry is now suspect bump v1 to v2 in the key a one-line commit every entry orphaned one cold build, then normal Selective purging is slower, incomplete, and depends on knowing exactly which entries were affected β€” which you rarely do. Include the prefix from day one; it costs nothing and is the only fast way out of a bad key.

Common pitfalls

  • Hashing the manifest, not the lockfile. package.json can be stable while the lockfile updates. Always hash the lockfile for dependency caches.
  • Including a commit SHA or timestamp in the key. That guarantees a unique key per run and a 0% hit rate. Key only on stable inputs; use restore-keys for fallback, never volatility.
  • restore-keys too loose. A bare ${{ runner.os }}- fallback can restore a cache built for a different Node version or config, reintroducing stale artifacts. Keep fallbacks scoped to compatible configurations, as covered in the parent guide.

Building the key from first principles

A cache key is a claim: anything that produced this key produced identical output. Working from that claim rather than from a template makes the design mechanical.

Start by listing what the task reads. For a dependency install that is the lockfile, the package manager version, the runtime version and the platform. For a compile step it is the source files, the compiler configuration, and any environment variable the compiler consults. For a container build it is the Dockerfile and the build context. The list is usually shorter than expected and always longer than the key someone wrote by hand.

Then remove anything that does not affect the output. The branch name usually does not. The commit SHA definitely does not β€” including it guarantees the key matches exactly once, which is a cache that never hits and is worse than none. The run identifier and any timestamp fall in the same category.

What remains is the key. If the list feels uncomfortably long, that discomfort is the correct signal: a task with many inputs is a task that will rarely hit, and the honest response is either to narrow its inputs or to accept a low hit rate rather than to shorten the key and accept wrong restores.

Two failure directions, one safe default

Getting a key wrong fails in one of two ways, and they are not symmetric. A key that is too specific produces a miss: the build is slower, the log says the cache was not found, and someone notices within a day. A key that is too loose produces a hit that restores output built from different inputs: nothing reports an error, the artifact is subtly wrong, and the symptom appears somewhere unrelated, days later.

Because the failures are asymmetric, the default when uncertain is to make the key more specific. An unnecessary miss costs minutes of runner time. An unsound hit costs a debugging session that may not even identify the cache as the cause.

The escape hatch to include from day one

Include a literal version component β€” v1- β€” in every key from the beginning. When a key turns out to be missing an input, every entry stored under it is already unsound and none of them can be trusted. Incrementing the prefix invalidates all of them in a one-line commit, without needing delete permissions on the backend or knowing which entries were affected.

Selective purging is the alternative, and it is worse in every dimension that matters during an incident: slower, dependent on knowing a blast radius you usually do not know, and incomplete if you guess wrong. The prefix costs nothing while it is unused and is the fastest available fix on the day it is needed.

← Back to Securing and Invalidating Build Caches