# GitHub Actions caching strategy: dependencies, builds and Docker (https://tenki.cloud/blog/github-actions-cache-strategy)

> For the complete documentation index, see [llms.txt](https://tenki.cloud/llms.txt)

- Author: Eddie Wang (Engineering)
- Published: Jan 16, 2026
- Updated: Sep 25, 2026
- Category: [CI/CD](https://tenki.cloud/blog/category/ci-cd)
- Reading time: 16 min

How the GitHub Actions cache matches keys, scopes and evicts entries, and how to cache npm, pnpm, Go, Python, Rust, Turborepo and Docker layers.

Every GitHub Actions job starts on a clean machine. Without a cache, each run downloads, compiles and rebuilds what the previous run already did, and you pay for those minutes every time.

This guide covers how the cache looks up and scopes entries, how to design keys that hit, and how to cache package manager downloads, build outputs such as Turborepo's task cache, and Docker image layers. It ends with what changes on [Tenki Runners](https://tenki.cloud/products/runners).

## How the Actions cache works

### Key matching

`actions/cache` takes a `path`, a `key` and an optional list of `restore-keys`. When restoring, it looks for, in order ([GitHub docs](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-key-matching)):

1. An exact match for `key`.
2. A prefix match for `key`.
3. Each entry in `restore-keys`, in the order listed, first as an exact match and then as a prefix.

It runs that search in the current branch first, then repeats it against the default branch. When several caches match a prefix, the most recently created one wins.

Only an exact match on `key` is a hit, and only then is the `cache-hit` output `true`. Otherwise the action saves a new entry under `key` at the end of the job, if the job succeeded. Entries are immutable: to change a cache, you save a new one under a new key.

Keys can be up to 512 characters. Each entry also carries a [version](https://github.com/actions/cache#cache-version), a hash of the `path` list and the compression tool, so the same key with different paths creates separate entries. Windows runners compress differently, so a cache saved on Linux or macOS won't restore on Windows unless the Windows job sets `enableCrossOsArchive: true`.

### Branch and pull request scope

Caches are isolated by branch ([access restrictions](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache)):

* A run can restore caches from its own branch and from the default branch.
* A pull request run can also restore caches from its base branch.
* A run can't restore caches from sibling or child branches, or from a different tag.
* A cache saved by a `pull_request` run belongs to the merge ref (`refs/pull/<n>/merge`). Only re-runs of that same pull request can restore it. The base branch and other pull requests can't.

The last rule is easy to miss. If a workflow only runs on `pull_request`, no pull request can reuse another's cache. Run it on `push` to the default branch as well, so pull requests have a shared cache to fall back to.

### Size, retention and eviction

By default a repository gets 10 GB of cache storage, and entries not accessed for 7 days are deleted ([usage limits](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#usage-limits-and-eviction-policy)). When a repository goes over its limit, GitHub still saves the new entry, then evicts entries in order of last access, oldest first.

Both limits can be raised. Since [November 2025](https://github.blog/changelog/2025-11-20-github-actions-cache-size-can-now-exceed-10-gb-per-repository/), admins on a paid plan or with a payment method on file can set a cache size limit of up to 10,000 GB per repository and a retention period of up to 90 days for public repositories or 365 days for private ones ([cache settings](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#configuring-cache-settings-for-your-repository)). The first 10 GB per repository stays free, and storage above it costs $0.07 per GB-month ([pricing](https://docs.github.com/en/billing/reference/actions-runner-pricing)). If a budget on cache storage runs out, the cache goes read-only.

Rate limits are 200 cache uploads and 1,500 downloads per minute per repository.

## Designing cache keys

A good key has three parts: a prefix naming what's cached, context that changes the contents (OS, runtime version), and a hash of the files that define them:

```yaml
key: ${{ runner.os }}-node24-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
  ${{ runner.os }}-node24-npm-
```

`hashFiles` returns a SHA-256 of the matched files, so the key changes only when the lockfile does. When it changes, the restore key pulls the previous cache, and the package manager downloads only what's new.

The common mistake goes the other way:

```yaml
# Don't: a new entry on every commit, with no fallback
key: ${{ runner.os }}-npm-${{ github.sha }}
```

This key never hits on the next commit and writes a fresh entry on every push. Those entries fill the quota and evict caches other jobs still need, often before anyone restores them.

A per-commit key only makes sense for caches whose contents change on every run, such as a build tool's task cache, and only with a restore key that finds the previous entry (see the Turborepo section).

To start over with a clean cache, add a version segment to the prefix (`-v2-`) and bump it. You can also delete entries from the repository's Actions > Caches page, or with the GitHub CLI: `gh cache delete <key>` for one, or `gh cache delete --all`.

## Restore and save as separate steps

`actions/cache` restores at the start of a job and saves in a post step when the job succeeds. For more control, split it into [`actions/cache/restore`](https://github.com/actions/cache/tree/main/restore) and [`actions/cache/save`](https://github.com/actions/cache/tree/main/save):

```yaml
- uses: actions/cache/restore@v6
  id: restore
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

- run: npm ci
- run: npm test

- uses: actions/cache/save@v6
  if: github.ref == 'refs/heads/main' && steps.restore.outputs.cache-hit != 'true'
  with:
    path: ~/.npm
    key: ${{ steps.restore.outputs.cache-primary-key }}
```

Only pushes to `main` write the cache, and every branch reads it, so branch-only caches stop eating into the quota. Prefix the `if` with `always() &&` to save even when tests fail; the combined action only saves after a successful job.

Two more inputs are useful:

* `lookup-only: true` checks whether an entry exists without downloading it, so a setup job can decide whether the cache needs rebuilding.
* `fail-on-cache-miss: true` fails the step when nothing matches, for jobs that must not run without something an earlier job cached. Leave `restore-keys` empty, or a partial match counts.

## Dependency caching by ecosystem

GitHub's `setup-*` actions cache package manager downloads for you ([supported setup actions](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#setup--actions-for-specific-package-managers)). Use them before reaching for `actions/cache` by hand.

### Node.js: npm, Yarn and pnpm

```yaml
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: npm
- run: npm ci
```

`setup-node` caches the package manager's global cache (`~/.npm` for npm), keyed on the lockfile. It [doesn't cache `node_modules`](https://github.com/actions/setup-node#caching-global-packages-data): `npm ci` still reinstalls `node_modules` from the lockfile, but reads packages from local disk instead of the registry. Since v6, npm caching turns on automatically when `package.json` has a `packageManager` (or `devEngines.packageManager`) field set to npm; Yarn and pnpm still need `cache`. For privileged workflows that don't need a cache, the README recommends `package-manager-cache: false`.

For pnpm, install pnpm first. [`pnpm/action-setup`](https://github.com/pnpm/action-setup) can cache the pnpm store itself:

```yaml
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
  with:
    cache: true # caches the pnpm store, keyed on pnpm-lock.yaml
- uses: actions/setup-node@v7
  with:
    node-version: 24
- run: pnpm install --frozen-lockfile
```

`version` can be left out when `package.json` has a `packageManager` field. `pnpm install` still runs on a hit to link packages from the store into `node_modules`, but downloads nothing already in the store.

### Caching node\_modules directly

Restoring `node_modules` lets you skip the install step altogether:

```yaml
- uses: actions/cache@v6
  id: deps
  with:
    path: node_modules
    key: ${{ runner.os }}-node24-modules-${{ hashFiles('**/package-lock.json') }}

- if: steps.deps.outputs.cache-hit != 'true'
  run: npm ci
```

It's faster on a hit, and it can go wrong in two ways. Native modules such as `sharp` or `bcrypt` are built for one OS and Node.js ABI, so put both in the key. And don't give a `node_modules` cache `restore-keys`: a partial match restores a tree built from a different lockfile, where a removed package can stay installed and keep tests green. With an exact key only, a changed lockfile always gets a clean `npm ci`.

### Python

```yaml
- uses: actions/setup-python@v7
  with:
    python-version: "3.13"
    cache: pip
```

Caching is off unless you set `cache`. For `pip`, `setup-python` caches pip's global cache, keyed on `requirements.txt` or `pyproject.toml`; for `pipenv` and `poetry`, it caches the virtualenvs. Unpinned requirements resolve to the newest release on every run, which makes the cache less useful.

### Go

```yaml
- uses: actions/setup-go@v7
  with:
    go-version-file: go.mod
    cache-dependency-path: go.sum
```

`setup-go` caches by default, and saves both the module cache (`GOMODCACHE`) and the build cache (`GOCACHE`), which is where compile time is saved. Since v6 the key hashes `go.mod`; set `cache-dependency-path: go.sum` to key on exact dependency versions.

### Rust

GitHub doesn't publish a Rust setup action with caching. [`Swatinem/rust-cache`](https://github.com/Swatinem/rust-cache) is the common choice:

```yaml
- run: rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
  with:
    save-if: ${{ github.ref == 'refs/heads/main' }}
```

It caches `~/.cargo` and the dependency artifacts in `./target`, keyed by job, rustc version and your `Cargo.lock` and `Cargo.toml` files, so install the toolchain first. Before saving, it prunes unused dependencies, incremental artifacts and your workspace's own crates, which keeps `target` from growing without bound.

### Gradle

Use [`gradle/actions/setup-gradle`](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md) rather than caching `~/.gradle` yourself. By default it writes the cache only from the default branch. Since v6 its default provider is proprietary (free for public repositories, in free preview for private ones); `cache-provider: basic` switches to an MIT-licensed one built on `actions/cache`.

## Caching build outputs with Turborepo

Dependency caches save download time. Build tool caches skip compiling and testing code that hasn't changed. Turborepo hashes each task's inputs, including source files, lockfile entries, `turbo.json` and the environment variables in `env` and `globalEnv` ([caching docs](https://turborepo.dev/docs/crafting-your-repository/caching)). On a match, it restores the task's `outputs` and replays its logs instead of running it.

### Local cache with actions/cache

Turborepo keeps its local cache in `.turbo/cache`. Older versions used `node_modules/.cache/turbo`. On a fresh runner that directory is empty, so persist it between runs with `actions/cache`, the way [Turborepo's GitHub Actions guide](https://turborepo.dev/docs/guides/ci-vendors/github-actions) does:

```yaml
- uses: actions/cache@v6
  with:
    path: .turbo
    key: ${{ runner.os }}-turbo-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-turbo-
```

The key is per commit because task results change with every commit, and the restore key picks up the most recent entry. Each run saves a new entry, so watch their size against your quota.

### Remote cache

A remote cache is shared across runners, branches and developer machines, and doesn't count against the GitHub quota. [Vercel Remote Cache](https://turborepo.dev/docs/core-concepts/remote-caching) is free on all Vercel plans, even if you don't host there. In CI, set:

```yaml
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
```

`TURBO_TOKEN` is the bearer token and `TURBO_TEAM` your team slug; Turborepo's guide also documents an OIDC setup with no long-lived token. The Remote Cache API has an open spec, with self-hosted servers such as [`ducktors/turborepo-remote-cache`](https://github.com/ducktors/turborepo-remote-cache). Point Turborepo at one with `TURBO_API`.

To let pull requests read the remote cache without writing to it, pass `--cache=local:rw,remote:r` or set the same value in [`TURBO_CACHE`](https://turborepo.dev/docs/reference/system-environment-variables). Turborepo 2.9 deprecated the older `TURBO_REMOTE_CACHE_READ_ONLY`. To sign artifacts with HMAC-SHA256, set `"signature": true` under `remoteCache` in `turbo.json` and provide `TURBO_REMOTE_CACHE_SIGNATURE_KEY`.

### Environment variables and wrong cache hits

If output depends on an environment variable that isn't in the hash, a run with a different value still hits and restores output built with the old one. Turborepo's docs use an API base URL as the example, where a preview build ends up in production. Strict mode, the default in 2.x, passes tasks only the variables in `env` and `globalEnv` plus inferred framework prefixes such as `NEXT_PUBLIC_*`. Declare every variable that changes the output.

To debug a miss, `turbo run build --dry` shows each task's hash inputs without running anything, and `--summarize` writes a run summary you can diff against another run.

Nx works along the same lines, with a local cache in `.nx/cache` and Nx Cloud as the remote option. To run only the packages a change affects, see [monorepo CI with selective builds](https://tenki.cloud/blog/monorepo-ci-github-actions-selective-builds), which covers `turbo --affected` and `nx affected`.

## Docker layer caching

Each Dockerfile instruction produces a layer, and BuildKit skips an instruction when the layer it would produce is already in its cache. On a fresh runner the builder's cache is empty, so without an external cache every layer rebuilds. BuildKit can export its cache to several [backends](https://docs.docker.com/build/cache/backends/).

### The GitHub Actions cache backend

```yaml
- uses: docker/setup-buildx-action@v4

- uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    cache-from: type=gha,scope=api
    cache-to: type=gha,mode=max,scope=api
```

`type=gha` stores layers in the same Actions cache as `actions/cache` ([gha backend](https://docs.docker.com/build/cache/backends/gha/)), with the same branch scope rules and the same quota. A large image with `mode=max` can crowd out your dependency caches. Also:

* `docker/setup-buildx-action` is required. The default `docker` driver can't export to this backend.
* `mode=max` exports layers from every stage of a multi-stage build. The default, `mode=min`, exports only the layers in the final image, so on a fresh runner the earlier stages rebuild even when nothing in them changed.
* `scope` defaults to `buildkit`. If one repository builds several images, give each its own scope, or they overwrite each other's cache.
* Since April 15, 2025, only version 2 of GitHub's cache service API works. Buildx 0.21.0 and BuildKit 0.20.0 or newer support it ([Docker docs](https://docs.docker.com/build/ci/github-actions/cache/)). That mostly matters on self-hosted runners with older Docker installs.

### Registry cache

To keep layers out of the Actions cache, or share them across repositories, push the cache to a registry:

```yaml
cache-from: type=registry,ref=ghcr.io/my-org/api:buildcache
cache-to: type=registry,ref=ghcr.io/my-org/api:buildcache,mode=max
```

It counts against registry storage instead, with no branch scoping or 7-day expiry, and the job needs push access. The `inline` exporter embeds cache metadata in the image but only supports `mode=min`, so it suits single-stage builds. Cache mounts (`RUN --mount=type=cache`) aren't exported by either backend; Docker's docs describe a workaround that moves them through `actions/cache`.

## Cache security

Anyone who can open a pull request, from a fork included, can read the base branch's caches, and cache contents aren't signed or verified ([GitHub docs](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#best-practices-for-using-caches-securely)). Keep secrets and credentials out of cached paths.

The bigger risk is cache poisoning: an untrusted run writes an entry that a privileged workflow later restores and executes. `pull_request` runs can't, since their caches stay on the merge ref. `pull_request_target`, `issue_comment` and `workflow_run` run in the default branch's context, though, and since [June 2026](https://github.blog/changelog/2026-06-26-read-only-actions-cache-for-untrusted-triggers/) GitHub gives them a read-only cache token by default. Only trusted triggers such as `push`, `schedule` and `workflow_dispatch` can write to the default branch's cache.

The [`cache-mode`](https://github.blog/changelog/2026-09-10-control-github-actions-cache-access-with-cache-mode/) key (September 2026) sets access per workflow or job to `read`, `write`, `write-only` or `none`:

```yaml
jobs:
  release:
    runs-on: ubuntu-latest
    cache-mode: none # never restore anything into the job that holds release credentials
```

`cache-mode: write` on a low-trust trigger turns that protection back off.

## Measuring whether it works

GitHub doesn't report hit rates, but every cache step's `cache-hit` output says whether that step got an exact match. Log it or write it to the job summary, then compare hit rates on the default branch with those on feature branches.

For storage, the repository's Actions > Caches page lists every entry with its size and last access time. From the CLI:

```sh
gh cache list --sort size_in_bytes --limit 20
gh api repos/OWNER/REPO/actions/cache/usage
```

Falling hit rates with usage near the limit usually mean thrashing: consolidate keys, save only from the default branch, or move Docker layers to a registry. To see what caching saves, compare job durations on exact hits against misses. [GitHub Actions cost optimization](https://tenki.cloud/blog/github-actions-cost-optimization) covers the other levers on the bill.

## A complete workflow

`setup-node` caches npm downloads, Turborepo's remote cache covers build and test tasks, and the `gha` backend stores Docker layers:

```yaml
name: CI
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
      TURBO_CACHE: ${{ github.event_name == 'pull_request' && 'local:rw,remote:r' || 'local:rw,remote:rw' }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx turbo run lint test build

  image:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha,scope=app
          cache-to: type=gha,mode=max,scope=app
```

Running on pushes to `main` keeps a default-branch npm cache for pull requests to restore. Pull requests read the Turborepo remote cache without writing to it, and the image only builds on `main`, so the layer cache has one writer.

## Caching on Tenki Runners

[Tenki Runners](https://tenki.cloud/products/runners) run their own GitHub Actions cache, and the runner is already configured to use it ([caching docs](https://tenki.cloud/docs/runners/caching.md)). `actions/cache`, `setup-node`'s `cache` option and other tools built on the Actions cache API work without workflow changes, including in jobs that run in a `container:`.

The limits differ from GitHub's:

|          | GitHub-hosted                                            | Tenki Runners                                                                                                        |
| -------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Storage  | 10 GB per repository by default, configurable higher     | One pool per workspace: 100 GiB on Starter, 1,000 GiB on Team, shared with sandbox snapshots and volumes             |
| Price    | First 10 GB per repository free, then $0.07 per GB-month | $0.20 per GiB-month from the first byte ([pricing](https://tenki.cloud/docs/pricing.md)); serving the cache is included in the runner rate |
| Eviction | Least recently accessed first when over the limit        | Least recently used first when the workspace is over quota                                                           |
| Expiry   | 7 days unused by default, configurable                   | 7 days                                                                                                               |

A single entry can be up to 10 GiB. The cache can be switched off per workspace in the dashboard settings.

Tenki also runs transparent mirrors for npm, APT, Go modules, Cargo, Maven Central and the Gradle Plugin Portal, CocoaPods and Swift Package Manager, and Docker Hub, with the runner's tooling already pointed at them. Repeat downloads come from Tenki's network instead of the public internet, and misses fall through to the upstream registry. That helps jobs that miss the Actions cache, too.

The Docker Hub mirror is a pull-through cache for images. On Linux runners, Docker and BuildKit are set up to use it, so `FROM ubuntu:24.04` and `docker pull` get a local copy after the first pull, and you don't run into Docker Hub's anonymous pull rate limits. It doesn't store your build layers. For layer reuse between jobs, the docs point to registry caching (`cache-from` / `cache-to`) or storing layers with `actions/cache`, as on GitHub-hosted runners.

Moving a job is a `runs-on` change to a label such as `tenki-standard-medium-4c-8g` ([sizes](https://tenki.cloud/docs/runners/sizes.md)). The [quickstart](https://tenki.cloud/docs/runners/quickstart.md) walks through it.