How Do I Cache Yarn Berry Installs?
Cache the folder yarn config get cacheFolder prints, key it on yarn.lock, and run yarn install --immutable. Zero-install repos ship the cache in the checkout.
Cache the folder that yarn config get cacheFolder prints, key it on the hash of yarn.lock, and run yarn install --immutable so a lockfile that drifts fails the job rather than rewriting itself. A zero-install repository needs no cache step at all, because its package archives are committed to .yarn/cache and arrive with actions/checkout.
On WarpBuild runners, WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 and is enabled by default on Linux runners (caching documentation).
Answer
Yarn Berry stores every dependency as one zip archive per package. Four settings in .yarnrc.yml decide where those archives live and how the install reads them (Yarn configuration reference):
| Setting | Documented default | What it controls |
|---|---|---|
nodeLinker | pnp | Whether the install materializes node_modules or resolves imports through .pnp.cjs |
enableGlobalCache | true | Whether archives land in the global folder or in the project's .yarn/cache |
cacheFolder | ./.yarn/cache | The project cache path, used when the global cache is off |
globalFolder | ~/.yarn/berry | Root of the global folder; the global cache sits in its cache subdirectory |
Rather than hardcoding either path, ask Yarn. yarn config get cacheFolder prints the active location under both modes, on every platform, for every Yarn version, which keeps the workflow correct when someone flips enableGlobalCache in a pull request.
name: yarn-berry-ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
- name: Enable Corepack
run: corepack enable
- name: Resolve the Yarn cache folder
id: yarn
run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore the Yarn cache
uses: WarpBuilds/cache@v1
with:
path: |
${{ steps.yarn.outputs.dir }}
.yarn/unplugged
.yarn/build-state.yml
key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
restore-keys: |
${{ runner.os }}-yarn-
- run: yarn install --immutable
- run: yarn test--immutable is the line that turns the cache into a correctness check. Yarn refuses to write a new yarn.lock, so a dependency someone added without committing the lockfile fails the job instead of resolving quietly on the runner (yarn install reference). Yarn already enables immutable installs when it detects a CI environment (enableImmutableInstalls reference); passing the flag makes the behavior independent of that detection, which matters when the same workflow runs in a container or through a self-hosted path where the environment variables differ.
The key hashes .yarnrc.yml alongside yarn.lock because the linker and the cache mode change what the restored directory contains. Change nodeLinker and the old entry stops describing the install.
If you want fewer moving parts, WarpBuilds/setup-node resolves the Yarn cache folder itself and keys the entry on the lockfile (setup actions documentation):
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: yarn
cache-dependency-path: yarn.lockThat covers the archives. It does not cover .yarn/unplugged, which is why repositories with native dependencies usually keep the explicit cache step above.
Detail
A zero-install repository and a cached global folder hold the same archives in different places
Both models exist so that a fresh machine can install without reaching the network. The difference is who stores the bytes.
| Zero-install | Cached global folder | |
|---|---|---|
| Where the archives live | .yarn/cache inside the repository, committed to git | ~/.yarn/berry/cache by default, outside the working tree |
| Required settings | enableGlobalCache: false | enableGlobalCache: true, the Yarn 4 default |
| How a runner gets them | actions/checkout | A cache restore step keyed on yarn.lock |
| What grows | Repository size and clone time on every runner and every developer machine | Cache storage, billed per GB-month |
| Failure mode | A missing archive means a commit that nobody validated | A cold entry means one slow install |
For a zero-install repository the job drops the cache step and tightens the install instead:
- uses: actions/checkout@v4
- run: corepack enable
- run: yarn install --immutable --immutable-cache--immutable-cache aborts when the install would add to or modify the cache folder, which is the check that keeps a zero-install repository honest. Without it, a package missing from the committed archives is fetched from the registry and the workflow passes, which defeats the reason the archives were committed.
Yarn's own guidance on which of these files belong in git is on the gitignore question in the Yarn docs: .yarn/cache and .pnp.cjs are committed under the zero-install model, while .yarn/unplugged, .yarn/build-state.yml, and .yarn/install-state.gz stay out of the repository. Those last three are exactly the paths a cache step is useful for.
The node-modules linker and the PnP linker need different paths
Under nodeLinker: node-modules, the archives are an input to the install and nothing more. Yarn expands them into a node_modules tree, the tree is what Node reads at run time, and the tree is disposable. Caching the archive folder is enough, and caching node_modules itself trades one decompression pass for hundreds of thousands of small files plus platform-specific binaries. The answer on caching node_modules works through that trade.
Under nodeLinker: pnp, the archives are the installation. .pnp.cjs maps every dependency to a path inside the cache folder and Node reads modules out of the zips through the PnP resolver (Plug'n'Play documentation), so the cache directory has to stay on disk for the whole job, through yarn test and any step that imports a dependency. Two extra paths join it. Packages that ship build scripts, or that need real files on disk, are unplugged into .yarn/unplugged, and .yarn/build-state.yml records which of them have already been built. Leave those out of the cached paths and every run recompiles the native dependencies even though the archive restore reported a hit.
So the path list differs by linker: one folder for node-modules, three for PnP. Keep them in one entry per repository, because the cache version is a hash of the compression tool and the list of paths in the step, and a step listing three paths cannot restore an entry written by a step listing one.
Scope, version, and expiry decide whether the restore matches
Three rules from the caching documentation explain most misses.
Entries are scoped to key, version, and branch, so a pull request does not read another pull request's entry. It falls back through restore-keys to the entry main wrote, then writes its own.
Entries expire 7 days after last use. A repository merging daily stays warm; a release branch that builds monthly pays a cold install every time, whatever the key looks like.
A lockfile change is a full cold install by design. That is the correct behavior for a dependency bump, and it means the first run after every upgrade costs full price.
What it costs
Cache metering is separate from runner minutes. Rates from the caching documentation and the pricing page, checked on 2026-08-13: storage is $0.20 per GB-month, and each write, restore, or list operation is $0.0001. Both lines are free on BYOC runners, where the storage sits in your own cloud account.
Worked model for one repository. Replace the durations with your own numbers from the jobs report; the arithmetic is the part worth copying. Assume 900 jobs a month, a 1.1 GB global cache, and two lockfile states live at once.
| Line item | Quantity | Rate | Monthly |
|---|---|---|---|
| Cache storage | 2.2 GB | $0.20 per GB-month | $0.44 |
| Restores | 900 | $0.0001 each | $0.09 |
| Saves | 100 | $0.0001 each | $0.01 |
| Total | $0.54 |
If yarn install runs 70 seconds cold and 18 seconds warm, the cache removes 52 seconds from 900 jobs, or 780 minutes. On warp-ubuntu-latest-x64-4x at $0.008 per minute those minutes are worth $6.24, against a $0.54 cache bill. The crossover sits at 67.5 minutes of install time a month at that rate.
Runner rates, with GitHub list prices from the GitHub Actions minute multipliers reference, checked on 2026-08-13:
| Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute |
|---|---|---|---|---|
| 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | $0.004 | ubuntu-latest on private repositories | $0.006 |
| 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 |
| 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 |
At 4 vCPU that is $0.008 against $0.012, a 33 percent lower list price on the same shape (GitHub pricing, checked on 2026-08-13).
Where this configuration stops applying
WarpBuild Cache is not supported on Windows runners (caching documentation), so a Windows Yarn job keeps actions/cache and GitHub Actions Cache with the same path list.
When the restore transfer becomes the cost, a file-level cache is the wrong lever. A snapshot runner boots a job from a machine image with the cache folder and the unplugged tree already populated. The guide to persistent caches for GitHub Actions runs works through the arithmetic that picks between the two mechanisms.
Related Questions
Does a zero-install repository still need a cache step?
No. The archives arrive with actions/checkout because .yarn/cache is committed, so there is nothing to restore. Run yarn install --immutable --immutable-cache to make the job fail when a package is missing from the committed cache instead of silently downloading it. The persistent caches guide compares that model with a restored cache over a month of jobs.
Should I cache node_modules when nodeLinker is node-modules?
No. Cache the folder that yarn config get cacheFolder prints and let the install materialize node_modules from the archives on every run. A node_modules tree holds platform-specific binaries and hundreds of thousands of small files, so it restores slower and matches fewer runs than the archives it came from. The node_modules answer covers the keying difference in detail.
The cache restores but yarn install still takes a minute. Why?
Packages with build scripts are unplugged to .yarn/unplugged and rebuilt when that directory is empty. Add .yarn/unplugged and .yarn/build-state.yml to the cached paths so the compiled output survives between jobs, and remember that changing the path list gives the entry a new cache version, so the first run after the change starts cold.
Does this differ from caching a pnpm store?
The keying does not. Both hash a lockfile and both cache a content-addressed store rather than an installed tree. The store layouts and the linker options differ, and pnpm workflows on WarpBuild runners covers that side. For the rest of a JavaScript pipeline, including runner sizing for test shards, see Node.js builds on WarpBuild runners.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.