GitHub Actions caching strategy: dependencies, builds and Docker
How the GitHub Actions cache matches keys, scopes and evicts entries, and how to cache npm, pnpm, Go, Python, Rust, Turborepo and Docker layers.
Eddie Wang
Updated 16 min read
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.
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):
- An exact match for
key. - A prefix match for
key. - 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, 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):
- 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_requestrun 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). 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, 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). The first 10 GB per repository stays free, and storage above it costs $0.07 per GB-month (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:
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:
# 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 and actions/cache/save:
- 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: truechecks whether an entry exists without downloading it, so a setup job can decide whether the cache needs rebuilding.fail-on-cache-miss: truefails the step when nothing matches, for jobs that must not run without something an earlier job cached. Leaverestore-keysempty, or a partial match counts.
Dependency caching by ecosystem
GitHub's setup-* actions cache package manager downloads for you (supported setup actions). Use them before reaching for actions/cache by hand.
Node.js: npm, Yarn and pnpm
- 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: 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 can cache the pnpm store itself:
- 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:
- 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
- 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
- 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 is the common choice:
- 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 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). 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 does:
- 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 is free on all Vercel plans, even if you don't host there. In CI, set:
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. 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. 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, 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.
The GitHub Actions cache backend
- 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), 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-actionis required. The defaultdockerdriver can't export to this backend.mode=maxexports 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.scopedefaults tobuildkit. 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). 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:
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). 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 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 key (September 2026) sets access per workflow or job to read, write, write-only or none:
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:
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 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:
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 run their own GitHub Actions cache, and the runner is already configured to use it (caching docs). 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); 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). The quickstart walks through it.


