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 buildFor 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 buildStep-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.
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 β hitExpected: identical inputs yield identical keys and a hit; any output-affecting change yields a new key and a miss.
Common pitfalls
- Hashing the manifest, not the lockfile.
package.jsoncan 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-keysfor fallback, never volatility. restore-keystoo 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.
Related
- Securing and Invalidating Build Caches β the parent guide on correctness, isolation, and invalidation.
- Docker Layer Caching for Full-Stack Applications β the per-layer key model these principles map onto.
- Best Practices for Caching npm vs Yarn vs pnpm in CI β dependency-cache specifics per package manager.
- Build Optimization & Caching Strategies β the section overview.
β Back to Securing and Invalidating Build Caches