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

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.

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.

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.

← Back to Securing and Invalidating Build Caches