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.
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