Server

GitHub Actions Cache Strategies That Actually Speed Things Up

By Prabath Thalangama· October 24, 2026· 3 min read
#github-actions#ci-cd#caching

Introduction

CI spends most of its time reinstalling the same dependencies. actions/cache fixes that — but only if the key strategy is right. A misused cache silently misses every run and you've gained nothing.

How the key works

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-
  • key — exact match. If a cache with this key exists, restore it (a "cache hit") and skip saving at the end.
  • restore-keys — prefix fallbacks, tried in order, newest match wins. On a hit here it's a partial restore; the job still saves a new cache under key at the end (because the exact key didn't match).
  • The hashFiles(...) in the key means: when the lockfile changes, the key changes, you get a partial restore from restore-keys (last good deps), npm installs the delta, and a fresh full cache is saved.

Anti-pattern: a static key like key: npm-cache. It saves once and never updates — you're permanently on the first run's dependencies.

Language dependencies — use the built-in caching

The setup-* actions have caching built in and handle the keys for you:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm          # or yarn, pnpm

- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
    cache: pip          # cache-dependency-path for non-standard locations

- uses: actions/setup-go@v5
  with:
    go-version: "1.23"
    cache: true         # on by default

Only hand-roll actions/cache for things the setup actions don't cover (a node_modules cache instead of ~/.npm, a build cache, a tool download).

Docker layer caching

Registry-backed is the most reliable in CI:

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    push: true
    tags: ghcr.io/org/app:latest
    cache-from: type=registry,ref=ghcr.io/org/app:buildcache
    cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max

type=gha (GitHub's cache backend) also works and needs no registry, but shares the 10 GB repo limit and evicts aggressively. mode=max caches intermediate stages too — essential for multi-stage builds.

The limits

  • 10 GB per repository. When exceeded, GitHub evicts least-recently-used entries.
  • Caches are scoped to a branch. A branch can restore from its own caches, its base branch, and the default branch — not from sibling feature branches. So the first CI run on a new branch restores from main's cache.
  • Caches untouched for 7 days are deleted.

Verification and troubleshooting

  • The job log shows Cache restored from key: ... or Cache not found for input keys: .... Check which.
  • Cache never hits on feature branches — expected on the first run; it restores from the default branch. If main has no cache either, seed it by running CI on main once.
  • Hit, but install still takes full time — you cached the wrong path (~/.npm download cache vs node_modules), or npm ci deletes node_modules first (cache ~/.npm for npm ci, or cache node_modules keyed on the lockfile and use npm ci --prefer-offline).
  • Key changes every run — hashFiles matching a file CI generates (a timestamp, a computed lockfile). Hash only committed inputs.
  • Docker cache-from ignored — no setup-buildx-action (the default docker driver can't import caches), or the buildcache ref doesn't exist yet (first run).
  • Cache size ballooning, evictions hurting — you're caching build outputs or .git. Cache inputs, not artifacts; use actions/upload-artifact for outputs.
  • Matrix jobs racing to save the same key — only the first save wins, the rest log a harmless conflict. Include the matrix dimension in the key if the contents differ per-variant.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.