Introducing Tenki's code reviewer: deep, context-aware reviews that actually find bugs.Try it for Free
GitHub Actions|Caching|+4
Jan 2026

GitHub Actions Cache Strategy Deep Dive

Eddie Wang
Eddie Wangengineering

Share Article:

A fresh CI run downloads dependencies, compiles code, and pulls base images from scratch every time. On a mid-size Node project, that's 2–4 minutes of pure waste per push. Multiply by dozens of daily commits and you're burning hours of billable runner time on work that's already been done.

GitHub Actions gives you a built-in caching layer, but most teams barely scratch the surface. They slap actions/cache on the lockfile and call it done. The result: partial cache hits, bloated storage, and workflows that still feel slow.

This guide covers three distinct caching layers: dependency caching, build artifact caching, and Docker layer caching. Each has different mechanics, trade-offs, and eviction behavior. Getting all three right is how you cut workflow duration by 30–60%.

How Actions/Cache works under the hood

Before optimizing anything, you need to understand the cache lookup algorithm. The actions/cache action follows a strict three-step search:

  1. Exact match on the full key. If the key you specified matches a stored cache entry exactly, it restores it and reports a cache hit.
  2. Prefix match on the key. If no exact match exists, it looks for any cache whose key starts with your key string.
  3. Restore keys. If both fail, it walks through your restore-keys list sequentially, using the same exact-then-prefix matching for each one.

When a restore key produces a partial match, the action restores the most recently created cache that matches. After the job succeeds, it saves a new cache under your original key. You can't update an existing cache entry; you can only create new ones.

Branch scoping rules

Caches are scoped by branch. A workflow on a feature branch can read caches from its own branch and from the default branch (usually main), but it can't read caches from sibling branches or child branches. PR workflows can also read from the PR's base branch.

This matters more than people realize. If your default branch never runs the caching step (say you only trigger on PRs), feature branches won't have a fallback cache to restore from. The fix is simple: make sure your default branch runs at least one workflow that populates the cache.

Eviction: the 10 GB Limit

Every repository gets 10 GB of cache storage by default. Enterprise and organization owners can increase this (up to 10 TB per repo), but any usage beyond 10 GB is billed. Cache entries that haven't been accessed in 7 days get deleted automatically. When you exceed the storage limit, GitHub evicts entries starting with the oldest last-accessed date.

Overly granular cache keys are the fastest way to blow through 10 GB. If every commit SHA generates a unique cache key and you don't structure your restore keys properly, you'll create a new multi-hundred-megabyte cache entry on every push. That leads to cache thrashing: entries get evicted before anyone can use them.

# Good: cache key changes only when lockfile changes
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
  ${{ runner.os }}-npm-

# Bad: unique key per commit, no restore fallback
key: ${{ runner.os }}-npm-${{ github.sha }}

The first pattern creates one cache per lockfile change and falls back gracefully. The second creates a new entry on every single push with no fallback, virtually guaranteeing cold misses across branches.

Dependency Caching by Ecosystem

Dependency caching is the easiest win. You're storing downloaded packages so the next run doesn't re-fetch them. The approach varies by language, and GitHub's setup-* actions now handle the common cases with a single parameter.

Node.js (npm, Yarn, pnpm)

The simplest approach is actions/setup-node with its built-in cache parameter. It automatically detects your package manager and caches the global package directory.

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'

This caches ~/.npm (or the equivalent for Yarn/pnpm), not node_modules. That distinction matters. Caching node_modules directly can cause version drift issues if the lockfile changes between cache saves. Caching the global store means npm ci still runs (ensuring lockfile integrity), but it resolves packages from local disk instead of the network.

For large monorepos, you might want to cache node_modules directly to skip the install step entirely. That's a valid optimization when you control the lockfile carefully, but it trades safety for speed.

Python (pip, pipenv, Poetry)

Same pattern, different action:

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'

This caches pip's download cache. For Poetry or pipenv, change the cache value accordingly. One gotcha: pip's cache can get large if you're building wheels from source (think numpy, scipy, or any package with C extensions). Keep an eye on your cache size if you see unexpected bloat.

Go Modules

- uses: actions/setup-go@v5
  with:
    go-version: '1.22'
    cache: true

Go's setup-go action caches both the module download cache (~/go/pkg/mod) and the build cache (~/.cache/go-build). That second directory is the real win for Go projects. Go's compile times are fast, but on large projects the build cache can still shave 30–50% off compilation.

Rust (Cargo)

Rust doesn't have an official setup-rust from GitHub, but Swatinem/rust-cache has become the de facto standard. It caches the Cargo registry index, downloaded crates, and compiled build artifacts.

- uses: Swatinem/rust-cache@v2
  with:
    shared-key: 'build'

Rust build artifacts are notoriously large. The target directory on a mid-size project can easily hit 2–5 GB. The rust-cache action handles pruning automatically, removing unused artifacts before saving to keep the cache within GitHub's limits.

Build Artifact Caching

Dependency caching saves you the download time. Build artifact caching saves you the compilation time. These are different problems with different solutions.

Turborepo

Turborepo hashes each task's inputs (source files, env vars, dependencies) and stores the outputs. When nothing changed, it replays the cached output instead of running the task.

By default, Turborepo uses a local file-system cache in node_modules/.cache/turbo. In CI, you can persist this across runs with actions/cache:

- uses: actions/cache@v4
  with:
    path: node_modules/.cache/turbo
    key: ${{ runner.os }}-turbo-${{ hashFiles('**/turbo.json') }}-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-turbo-${{ hashFiles('**/turbo.json') }}-
      ${{ runner.os }}-turbo-

For teams that want cross-runner cache sharing, Turborepo's remote cache (via Vercel or self-hosted) is the better option. It lets multiple CI runners share a single cache without going through GitHub's cache API.

Nx

Nx takes a similar approach but stores its cache in .nx/cache by default. Cache it the same way:

- uses: actions/cache@v4
  with:
    path: .nx/cache
    key: ${{ runner.os }}-nx-${{ hashFiles('**/nx.json') }}-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-nx-${{ hashFiles('**/nx.json') }}-
      ${{ runner.os }}-nx-

Nx Cloud provides a remote cache similar to Turborepo's, with the added benefit of distributed task execution. If you're running many parallel jobs, the remote option pays for itself quickly.

Gradle Build Cache

Gradle has its own build cache that works at the task level. The gradle/actions/setup-gradle action handles caching automatically, but you can also use actions/cache to store ~/.gradle/caches and ~/.gradle/wrapper.

- uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
      ${{ runner.os }}-gradle-

Gradle's cache can grow fast. Consider adding a cleanup step that removes unused entries after the build, or use the gradle/actions/setup-gradle action's built-in cache cleanup feature.

Docker Layer Caching

Docker image builds are where caching gets interesting (and complicated). Every Dockerfile instruction produces a layer, and Docker can skip rebuilding layers when nothing in that instruction or its predecessors changed. In CI, the problem is that each run starts with a cold Docker daemon.

There are three main strategies for persisting Docker build layers across CI runs, each with distinct tradeoffs.

GitHub Actions Cache Backend (type=gha)

BuildKit can store and retrieve layer cache directly from GitHub's cache service. This is the easiest option and the one most teams should start with.

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v4

- name: Build and push
  uses: docker/build-push-action@v7
  with:
    push: true
    tags: myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

The mode=max flag is critical. Without it, BuildKit only caches layers in the final build stage. With mode=max, it caches all intermediate stages too. For multi-stage builds, that's the difference between caching half your layers and caching all of them.

The downside: this shares the same 10 GB cache budget with your other actions/cache entries. A large Docker image can eat several gigabytes, crowding out your dependency caches.

Registry Cache Backend (type=registry)

Instead of using GitHub's cache API, you can push cache layers to a container registry as a dedicated cache image.

- name: Build and push
  uses: docker/build-push-action@v7
  with:
    push: true
    tags: user/app:latest
    cache-from: type=registry,ref=user/app:buildcache
    cache-to: type=registry,ref=user/app:buildcache,mode=max

This keeps your Docker cache entirely separate from GitHub's 10 GB pool. The trade-off is that pushing and pulling cache layers involves network round-trips to the registry, which adds latency. For large images, the GHA backend is usually faster because the cache service runs closer to the runner.

Registry caching shines when you're already close to the 10 GB limit, or when multiple repositories need to share the same base image cache.

Inline Cache

The inline exporter embeds cache metadata directly in the image manifest. It's the simplest registry-based option since you don't need a separate cache image, but it only supports min cache mode. That means only final-stage layers get cached. Not ideal for multi-stage builds.

Common Pitfalls

Caching has failure modes that are subtle enough to waste hours of debugging time.

Stale Caches Causing False Greens

This is the scariest cache problem. If you cache node_modules and someone removes a dependency from package.json without updating the lockfile hash, the old package stays in the cached node_modules. Tests pass in CI because the dependency is still there, but they fail on a fresh install. You've now got a green check on a broken build.

The fix: always use npm ci (which wipes node_modules and installs from the lockfile) and cache the global package store rather than node_modules itself. This applies to any language: cache the package manager's download directory, not the resolved output.

Cache Poisoning

GitHub's own docs warn about this: anyone with read access to a repository can create a pull request that writes to the cache. If malicious code runs during a PR workflow, it could inject compromised artifacts into the cache that subsequent builds on main might restore.

In practice, the branch scoping rules limit this. PR caches are scoped to the merge ref, so they don't directly pollute the base branch's cache. But if your cache keys are too broad and you use wide restore-key prefixes, a PR-created cache could be matched by a subsequent run. Never store secrets, tokens, or credentials in cached paths.

Cross-OS Cache Misses

Cache entries include OS metadata in their version stamp. A cache saved on ubuntu-latest won't match a restore attempt on windows-latest, even if the key string is identical. For cross-platform matrix builds, include ${{ runner.os }} in your key to avoid confusion. If you genuinely want cross-OS cache sharing (rare, but it happens), the enableCrossOsArchive input exists for that purpose.

Cache Thrashing from Over-Specific Keys

Including github.sha in your cache key without restore keys means every commit creates a new entry and never reuses an old one. That's the opposite of caching. The SHA should only be in the key when you also have broader restore keys that act as fallbacks, and even then, question whether the SHA adds value. For dependency caches, the lockfile hash is almost always the right granularity.

Before/After: Real-World Timing Patterns

To make this concrete, here are typical timing improvements from each caching layer on a mid-size project.

Node.js monorepo (Turborepo, ~50 packages)

  • No caching: ~8 minutes (2 min install, 6 min build+test)
  • Dependency cache only: ~6.5 minutes (30s install, 6 min build+test)
  • Dependency + Turbo cache: ~2.5 minutes (30s install, 2 min build+test with cache hits)

Docker build (multi-stage, Node app)

  • No caching: ~5 minutes
  • GHA cache backend (mode=max): ~1.5 minutes (layer restore + incremental build)
  • Registry cache backend: ~2 minutes (network overhead for cache pull/push)

Go service with Cargo dependency

  • No caching: ~7 minutes
  • Module + build cache: ~3 minutes

The pattern is consistent: dependency caching alone gives you 15–25% improvement, and adding build artifact or Docker layer caching pushes you toward 50–70% total reduction. The biggest gains come from the build layer, not the dependency layer.

Putting It All Together

Here's a complete workflow that layers all three caching strategies for a Node.js project that builds and pushes a Docker image:

name: Build and Deploy
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      # Layer 1: Dependency caching
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      # Layer 2: Build artifact caching (Turborepo)
      - uses: actions/cache@v4
        with:
          path: node_modules/.cache/turbo
          key: ${{ runner.os }}-turbo-${{ hashFiles('**/turbo.json') }}-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-turbo-${{ hashFiles('**/turbo.json') }}-
            ${{ runner.os }}-turbo-

      - run: npx turbo run build test

      # Layer 3: Docker layer caching
      - uses: docker/setup-buildx-action@v4

      - uses: docker/build-push-action@v7
        with:
          context: .
          push: ${{ github.ref == 'refs/heads/main' }}
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Each layer targets a different bottleneck. The dependency cache eliminates network downloads. The Turbo cache skips unchanged compilation tasks. The Docker cache avoids rebuilding unchanged image layers. Together, they compound.

One thing to watch: the combined GHA cache usage from actions/cache and type=gha Docker cache shares the same 10 GB pool. If your Docker images are large, consider moving Docker to registry caching and keeping GHA cache for dependencies and build artifacts. Check your cache usage in the repository's Actions settings to see where the space is going.

Start with dependency caching if you haven't already. Add build artifact caching once your install times are under control. Then tackle Docker layer caching when image builds are your bottleneck. Measure after each change. The numbers don't lie.

Recommended for you

What's next in your stack.

GET TENKI

Smarter reviews. Faster builds. Start for Free in less than 2 min.