How Do I Cache node_modules in GitHub Actions?
Cache the package manager store instead of node_modules: point WarpBuilds/setup-node@v6 at your lockfile with the cache input and let the action key it.
Last verified:
Answer
Cache the package manager store instead of the node_modules directory, and let the setup action do the keying for you. On WarpBuild runners that means WarpBuilds/setup-node@v6 with the cache input set to npm, yarn, or pnpm, and cache-dependency-path pointed at the lockfile that drives the install.
Here is the whole configuration for an npm repository:
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit-tests:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm testWarpBuilds/setup-node is a drop-in replacement for the upstream setup action. It takes the same inputs and produces the same outputs, and on WarpBuild runners it routes the cache through WarpBuild Cache with no extra configuration. Swapping the action reference is the only workflow edit required, as described in the setup actions documentation.
From v6 the action turns npm caching on by itself when package.json declares the package manager. If the packageManager or devEngines.packageManager field is set to npm, you get the cache without writing the cache input at all. Set package-manager-cache: false to turn that automatic behavior off.
What each value of cache actually stores:
cache value | What the action caches | Key file it hashes by default |
|---|---|---|
npm | The npm cache directory, ~/.npm | package-lock.json |
yarn | The yarn cache folder reported by yarn config | yarn.lock |
pnpm | The pnpm content addressable store | pnpm-lock.yaml |
None of those paths is node_modules. The action restores the packed artifacts that npm ci, yarn install, or pnpm install would otherwise download, and the install command still runs and still writes a node_modules tree that matches the lockfile exactly.
Detail
Why the store beats the directory
Three properties separate a store cache from a node_modules cache.
The first is keying. The store maps cleanly onto the lockfile: one lockfile state produces one set of packed tarballs, so hashFiles over the lockfile is an exact description of the cache contents. A node_modules tree is the output of resolving that lockfile against a specific Node version, a specific platform, and whatever postinstall scripts ran, so the lockfile hash under-describes it. Two runs with the same key can legitimately need different trees.
The second is restore shape. The store holds a few thousand compressed tarballs. A node_modules tree in a mid-sized repository holds hundreds of thousands of small files plus symlinks and platform binaries. Cache restore work scales with file count, and unpacking a tree of that shape is the expensive half of the operation. The store trades that for one decompression pass plus a local install that never touches the network.
The third is portability. Cache entries carry a version hash covering the compression tool used on the runner OS and the list of cached paths, so an entry written on one platform will refuse to match on another. A cache saved on warp-macos-latest-arm64-6x cannot restore on warp-ubuntu-latest-x64-4x because the versions differ. The store is content addressed and survives that split more predictably than a tree full of compiled native modules. Windows runners are the one gap: WarpBuild caching is not supported there, per the caching documentation.
The key, and what invalidates it
The cache is scoped to the key, the version, and the branch. That third scope is the one that surprises people. A feature branch does not read the entry written by another feature branch; it reads main's entry through the normal fallback path and writes its own. So the useful mental model is one warm entry per lockfile state per long-lived branch, plus short-lived entries that expire.
Entries expire after 7 days of last use. A branch that builds every day keeps its entry alive indefinitely. A release branch that builds monthly pays a cold install every time, and no amount of key tuning changes that.
A key change means a full cold install. With a lockfile-derived key that is the correct behavior: a dependency bump should not restore stale tarballs. It also means the first run after every dependency update costs full price, which is worth remembering when you read a single slow run as a cache failure. If your entries miss on runs where nothing changed, the cause is elsewhere, and why a GitHub Actions cache misses on every run walks the usual suspects.
Monorepos with more than one lockfile
cache-dependency-path accepts wildcards and multiple paths, which gives you two strategies.
A glob hashes everything into one key:
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: '**/package-lock.json'An explicit list scopes each job to the lockfiles it installs from:
jobs:
web:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: |
apps/web/package-lock.json
packages/ui/package-lock.json
- run: npm ci --prefix apps/webThe glob is right for a workspace with a single root install, where every job resolves the same dependency graph anyway. The explicit list is right when jobs install independently, because it stops a change in the api workspace from invalidating the web job's entry. Teams running pnpm workspaces usually have one root pnpm-lock.yaml and should use the glob; the per-job list matters most for repositories that grew several independent installs. pnpm workflows on WarpBuild runners covers the store layout in more detail, and Node.js builds on WarpBuild runners covers the rest of the pipeline.
When to reach for the generic cache action
The setup action covers the package manager store. Everything else in a Node repository that is worth caching needs the general purpose action. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 and takes the same inputs:
- name: Restore browser binaries
uses: WarpBuilds/cache@v1
with:
path: ~/.cache/Cypress
key: ${{ runner.os }}-cypress-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-cypress-Test runner binaries, build output caches, and generated type artifacts all belong here. Keep them in separate entries from the dependency store so that a dependency bump does not throw away a build cache that is still valid.
When the install cost is dominated by native module compilation that no store can replay, the cache layer stops being the right lever. Snapshot runners boot a job from a runner VM captured mid workflow, so the compiled state is already on disk when the job starts. The wider product surface around that includes remote Docker builders, CI observability, an MCP server, and the Action Debugger; the observability reports are where you check whether the install step is actually the thing costing you minutes before changing anything.
What it costs
Cache is billed separately from runner minutes. Rates from the caching documentation and the WarpBuild pricing page:
| Item | Hosted runners | BYOC |
|---|---|---|
| Cache storage | $0.20 per GB-month | Free |
| Cache write, restore, or list | $0.0001 per operation | Free |
Worked model for one repository. Suppose the npm store cache is 1.5 GB, three distinct lockfile states stay live across branches in a month, and the repository runs 1,500 jobs with 1,500 restores and 200 saves.
- Storage: 3 x 1.5 GB = 4.5 GB x $0.20 = $0.90 per month.
- Operations: 1,700 x $0.0001 = $0.17 per month.
- Cache total: $1.07 per month.
Now the minutes side, with illustrative install durations you should replace with your own numbers from the jobs report. Suppose npm ci takes 95 seconds cold and 25 seconds warm. That is 70 seconds per job, 1,500 jobs, or 1,750 billed minutes. On warp-ubuntu-latest-x64-4x at $0.008 per minute those minutes cost $14.00, against $1.07 of cache bill. The arithmetic is the point rather than the assumed durations: caching pays whenever the minutes it removes are worth more than the storage and operations it adds, and at these rates the crossover sits at roughly 134 minutes of install time per month.
The same minutes at GitHub-hosted 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, and at 8 vCPU $0.016 against $0.022, 27 percent lower (GitHub pricing, checked on 2026-08-13). Every cost claim on this page carries a number, a source link, and a checked-on date, and the same numbers appear on every WarpBuild surface.
Full rates by runner type are on the WarpBuild pricing page.
Related Questions
How do I set cache-dependency-path in a monorepo with several lockfiles?
Pass a multi line list or a glob. A glob such as **/package-lock.json hashes every lockfile in the repository into one key, so any package bump invalidates the entry for every job. An explicit list per job, for example apps/web/package-lock.json on the web job and apps/api/package-lock.json on the api job, keeps the two entries independent so a change in one workspace leaves the other warm. Pick the glob when workspaces share a single install, and the explicit list when each job installs on its own.
How long does a GitHub Actions dependency cache last on WarpBuild?
Cache entries expire after 7 days of last use, and you can delete an entry at any time from the action or the console. Active branches keep their entries alive by touching them on every run, so the entries that expire are the ones nobody restored for a week. A branch that builds once a month always pays the cold install cost, which is why the first run after a quiet period looks slow. How long GitHub Actions caches last covers the retention rules in full.
What does dependency caching cost on WarpBuild?
Cache storage bills at $0.20 per GB-month and every write, restore, or list operation bills at $0.0001 on hosted runners. Cache is free on BYOC, where the storage sits in your own cloud account. A repository holding 4.5 GB of store cache and running 1,500 jobs a month pays about $1.07. Full rates are on the WarpBuild pricing page.
Should I ever cache the node_modules directory itself?
Only when the install step is dominated by native module compilation that the store cannot replay, and only with a key that includes the runner OS, the architecture, and the Node major version. node_modules holds absolute paths, platform specific binaries, and symlinks, so an entry saved on one runner shape breaks on another. Cache the store first and measure the install step before reaching for the directory.
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.