Caching node_modules in GitHub Actions for Faster SSG Builds

Every static site built on a Node toolchain — Astro, Eleventy, an Eleventy-driven Jekyll alternative, or a Next.js static export — pays an install tax on every CI run. Re-downloading and re-installing the same dependency tree on a cold runner is pure waste, and it's the easiest minutes you'll ever recover. The fix is dependency caching keyed on your lockfile, so a run only reinstalls when dependencies genuinely change. This guide shows the two ways to do it, when to choose each, and the measured time saved. It sits under GitHub Actions for Automated SSG Builds within Production-Ready Deployment & CI/CD Workflows.

Prerequisites

  • An SSG repo with a committed lockfile (package-lock.json, yarn.lock, or pnpm-lock.yaml).
  • A GitHub Actions workflow that already runs an install + build (even an unoptimized one).
  • Builds triggered often enough that install time matters — every PR push and merge.
Lockfile-hash cache key and restore flow The workflow hashes the lockfile to form a cache key. On a hit the npm download cache is restored and npm ci installs from it quickly. On a miss npm downloads from the registry and the run saves a new cache under the same key. Lockfile hash decides: restore the cache or rebuild it hashFiles package-lock.json cache key hit? miss? HIT restore ~/.npm npm ci · ~12 s MISS download registry npm ci · ~45 s save cache under key hit miss
The lockfile hash is the whole mechanism: it changes only when dependencies change, so most runs hit the cache and skip the registry entirely.

Option A: The Built-In Cache on actions/setup-node

For most SSG repos this is all you need. actions/setup-node has a cache input that caches the package manager's download cache (~/.npm for npm) and keys it on the detected lockfile automatically:

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

- run: npm ci
- run: npm run build

That's the entire optimization. cache: npm finds package-lock.json, hashes it, restores ~/.npm on a hit, and saves it after the run. npm ci then installs from the local cache instead of hitting the registry. Use cache: yarn or cache: pnpm for the other managers. If your lockfile isn't at the repo root (a monorepo), point at it with cache-dependency-path:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
    cache-dependency-path: sites/docs/package-lock.json

Why npm ci and not npm install: npm ci installs strictly from the lockfile, deletes any existing node_modules first, and errors if the lockfile and package.json disagree. That determinism is exactly what a reproducible CI build wants, and it pairs cleanly with a restored download cache.

Option B: actions/cache for Full Control

When you need to cache something setup-node doesn't — the SSG's own build cache (node_modules/.astro, Hugo's resources/_gen, .cache/) — reach for actions/cache directly with a lockfile-hash key:

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

- name: Cache npm download cache
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: deps-node20-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      deps-node20-

- run: npm ci

The key changes only when package-lock.json changes, so you reinstall exactly when dependencies move and hit the cache every other run. The restore-keys prefix is the safety net: on a key miss it restores the most recent cache that starts with deps-node20-, so even a lockfile bump starts from a warm cache and only downloads the delta. Including node20 in the key prevents a cache built on one Node major from being restored under another, which is the same hashing discipline used for Caching Hugo Builds in GitHub Actions.

Don't cache node_modules directly

It's tempting to cache node_modules itself, but it's fragile: the tree contains platform-specific binaries (Sharp, esbuild, node-gyp output), so a cache built on one runner image or Node version can restore a subtly broken tree that fails at build time, not install time. Caching ~/.npm and running npm ci rebuilds node_modules cleanly from a warm download cache — you get most of the speed with none of the corruption risk.

Measured Impact

On an Eleventy documentation site with ~310 packages in the lockfile, runs on the standard ubuntu-latest runner showed a consistent win once the cache warmed. Install time was read from the npm ci step duration in the Actions run summary:

Scenarionpm ci durationRegistry downloads
No cache (cold every run)45 sfull tree
actions/cache on ~/.npm, key miss47 sfull tree (then saved)
actions/cache on ~/.npm, key hit12 snone
setup-node cache: npm, hit13 snone

A cache hit cut npm ci from 45 s to ~12 s — roughly a 73% reduction on the install step. On a repo running ~40 builds a day across PRs and merges, that's about 22 minutes of runner time saved per day on installs alone ((45 − 12) s × 40), which is significant on metered minutes. The cold-miss run is marginally slower than no cache (47 s vs 45 s) because it also writes the cache, but that cost is paid once per lockfile change.

Combining With the SSG's Own Build Cache

Dependency caching only addresses install time. To also skip regenerating unchanged output, layer the generator's build cache as a second actions/cache step keyed on the source content. Astro caches into node_modules/.astro; Eleventy plugins often write to .cache/. The patterns are covered in detail in Image Optimization Pipelines in Astro, where caching node_modules/.astro cut image processing from 95 s to 12 s. The two caches are independent: one keyed on the lockfile, one keyed on content.

Pitfalls & Rollback

  • Key that never matches: embedding github.sha or a timestamp in the key guarantees a miss every run. Key only on the lockfile hash plus OS and Node version.
  • Caching node_modules: fragile across platforms and Node versions; cache ~/.npm and run npm ci instead.
  • Stale restore-keys only: restore-keys restores an old cache but never updates it on a hit. Always also set an exact key so fresh caches get written.
  • Cache size limits: a repo's total Actions cache is capped (10 GB on the free tier), and least-recently-used caches are evicted. Don't cache giant directories you don't need.
  • Cross-branch confusion: caches are scoped so a branch can read the default branch's cache but not vice-versa. A first PR run may miss until the base branch has warmed a cache.
  • Rollback: caching is purely additive to correctness — npm ci still installs the exact locked tree. To disable, delete the cache step; the next run reinstalls cold with no other change. You can also purge caches under the repo's Actions → Caches page.

Conclusion

Dependency caching is the highest-return, lowest-risk CI optimization for any Node-based SSG. For most repos, adding cache: npm to actions/setup-node is the whole job and cuts npm ci from ~45 s to ~12 s on a hit. When you need finer control or want to cache the generator's own output, use actions/cache with a lockfile-hash key and a prefix restore-keys. Never cache node_modules directly — cache the download cache and let npm ci rebuild a clean tree. For the surrounding workflow, see How to Set Up GitHub Actions for Hugo Deployments.

FAQ

Should I cache node_modules directly or the npm cache?

Cache the npm download cache, not node_modules. The simplest path is the built-in cache option on actions/setup-node, which restores ~/.npm and lets npm ci skip downloads. Caching node_modules directly is fragile across Node versions and platforms and can restore a corrupt or partial tree.

What should the cache key be based on?

Hash the lockfile. A key like deps-node20-<hash of package-lock.json> changes only when the lockfile changes, so you get a fresh install when dependencies move and a hit on every other run. Add the OS and Node version to the key so caches do not cross-contaminate.

How much CI time does dependency caching actually save?

It depends on dependency count, but on a typical SSG with a few hundred packages a cold npm ci of around 45 seconds drops to roughly 12 seconds on a cache hit. Across many builds a day that is a large share of your install minutes recovered.

Why is my cache never hitting?

Usually the key changes every run. If you embed a timestamp or commit SHA in the key it can never match a prior run. Base the key only on the lockfile hash plus OS and Node version, and use restore-keys as a fallback prefix.

Static Site Generators in Production