Learn how to set up reliable npm caching in GitHub Actions on self‑hosted Windows runners using Node.js 20. Step‑by‑step guide with code.
I was in the middle of a release sprint when our CI started taking 15‑20 minutes just to install npm packages on a self‑hosted Windows runner. The pipeline used Node.js 20, the runner was a dedicated Windows Server 2022 VM, and we were pulling in a few hundred packages each run. The delay was eating into our deployment window and, worse, the flaky network on the VM sometimes caused npm install to fail outright.
The fix turned out to be a proper cache strategy that leveraged GitHub Actions’ built‑in actions/cache action, but the Windows specifics (path separators, PowerShell quirks, and the way Node 20 resolves the global cache) kept tripping me up. In this post I’ll walk through the exact steps I took to get deterministic, fast caching of npm dependencies on a Windows self‑hosted runner running Node.js 20.
Why does caching npm on a Windows self‑hosted runner need special handling?
When you run a GitHub Actions workflow on GitHub‑hosted Linux runners, the default actions/cache example works out of the box because:
- The npm cache lives under
$HOME/.npm– a path that is consistent across Linux shells. - The default
npm cicommand respects the cache without any extra flags. - The runner’s file system uses forward slashes, which match the glob patterns in the cache key.
On Windows self‑hosted runners the story changes:
- The user profile directory is
C:\Users\<user>and the npm cache lives underC:\Users\<user>\AppData\Roaming\npm-cache(or%APPDATA%\npm-cache). - PowerShell treats backslashes as escape characters in strings, so you have to be careful when you write glob patterns.
- Node.js 20 introduced a new default cache location when
npmdetects anode_modulesfolder with a lockfile: it prefers a project‑local cache undernode_modules/.cacheif you enablenpm config set cache .npm --global false. This can be a blessing or a curse depending on how you configure it.
If you ignore these nuances, the cache never hits, and you end up reinstalling everything each run – exactly what I experienced.
What is the minimal, reliable workflow file for caching npm on Windows?
Below is the broken version that I originally tried. It mirrors the Linux example from the docs but fails on Windows because the path pattern is wrong and the restore key doesn’t include the Node.js version.
name: CI
on: [push, pull_request]
jobs:
build-windows:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- run: npm ci
- run: npm test
Why it fails:
~/.npmis a Linux‑style path; on Windows the tilde expands to the PowerShell$HOMEvariable, but the cache action resolves it before PowerShell runs, so it points to a non‑existent folder.- The key does not include the Node.js version – if you ever upgrade from Node 18 to Node 20 the cache will be reused incorrectly, leading to binary mismatches.
- The restore‑keys block is too generic; it can restore a cache from a completely unrelated branch, causing nondeterministic builds.
Fixed workflow for Windows self‑hosted runners
Below is the working configuration. I break it down step‑by‑step after the code block.
name: CI – Windows Self‑Hosted
on: [push, pull_request]
jobs:
build-windows:
runs-on: self-hosted
defaults:
run:
shell: pwsh
steps:
# 1️⃣ Checkout the repo
- uses: actions/checkout@v4
# 2️⃣ Install the exact Node.js version we need (20.x)
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # <-- optional, but we’ll add a custom cache later for Windows specifics
# 3️⃣ Define the npm cache location for Windows
- name: Determine npm cache directory
id: npm-cache-dir
run: |
$npmCache = npm config get cache
# Normalize to Windows path without trailing backslash
$npmCache = $npmCache.TrimEnd('\')
echo "::set-output name=dir::$npmCache"
# The output is stored as ${{ steps.npm-cache-dir.outputs.dir }}
# 4️⃣ Restore the cache using the exact path we just discovered
- name: Cache npm dependencies (Windows)
uses: actions/cache@v3
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
# Include node version, OS, and lockfile hash in the key
key: ${{ runner.os }}-node20-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node20-npm-
# 5️⃣ Install deps – npm ci respects the cache automatically
- name: Install dependencies
run: npm ci
# 6️⃣ Run your test suite
- name: Run tests
run: npm test
What changed and why it works
defaults.run.shell: pwsh– forces allrunsteps to use PowerShell, which is the default on Windows self‑hosted runners. This avoids the hidden “cmd.exe” mode where environment variables behave differently.- Explicit npm cache discovery –
npm config get cachereturns the exact folder (e.g.,C:\Users\runneradmin\AppData\Roaming\npm-cache). We store it in an output variable so the cache action can reference it with the correct Windows path separator. - Cache key includes
node20– tying the key to the Node.js major version guarantees that a cache built with Node 18 won’t be reused when we switch to Node 20. - Restore‑keys hierarchy – we first try the exact hash of
package-lock.json. If that misses (e.g., a new branch without a cache), we fall back to any cache for the same OS and Node version, which still gives us a huge speed win. actions/setup-nodebuilt‑in npm cache – thecache: 'npm'flag tells the action to automatically create a cache for the global npm cache on Linux. On Windows it’s a no‑op, but keeping it makes the workflow portable if you ever run it on Linux as well.
How do I verify that the cache is actually being hit?
GitHub Actions emits clear log messages for actions/cache. After the Cache npm dependencies (Windows) step runs, look for one of these two phrases:
Cache restored from key ...– the cache was found and restored.Cache not found for key ...– a miss; a new cache will be saved at the end of the job.
You can also add a diagnostic step to print the cache directory contents before and after npm ci:
- name: List npm cache before install
run: Get-ChildItem -Recurse -Force "${{ steps.npm-cache-dir.outputs.dir }}" | Measure-Object
If the cache hit is successful, you’ll see a significant reduction in the number of files that npm ci needs to download. On my runner, a warm cache brings the npm ci time from ~12 seconds down to ~2 seconds.
What are the edge cases I need to guard against?
1️⃣ Changing the npm version inside the workflow
If you install a different npm version (e.g., npm i -g npm@9) after the cache is restored, the cache may become invalid because the lockfile format can differ. The safest pattern is:
- name: Set up Node.js 20 (includes npm)
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
check-latest: true # ensures we get the latest npm that ships with Node 20
If you must pin a specific npm version, include it in the cache key:
key: ${{ runner.os }}-node20-npm-${{ hashFiles('package-lock.json') }}-npm9
2️⃣ Using a monorepo with multiple package-lock.json files
When you have several packages, each with its own lockfile, you need a composite key. One approach is to hash all lockfiles together:
key: ${{ runner.os }}-node20-npm-${{ hashFiles('**/package-lock.json') }}
Be aware that the hash length can increase, but actions/cache truncates to 255 characters automatically.
3️⃣ Cleaning the cache manually
Sometimes you need to bust a stale cache (e.g., after a major dependency upgrade). The simplest way is to change the key – add a version suffix:
key: ${{ runner.os }}-node20-npm-${{ hashFiles('package-lock.json') }}-v2
GitHub will treat it as a brand‑new cache and start populating it on the next run.
4️⃣ Disk‑space constraints on the self‑hosted VM
Windows Server images often have a 50 GB root volume. npm caches can grow to several gigabytes for large projects. To keep the runner healthy, add a cleanup step that prunes old caches older than 30 days:
- name: Prune old npm caches
run: |
$cacheRoot = "C:\Users\runneradmin\AppData\Local\npm-cache"
Get-ChildItem $cacheRoot -Recurse -Force | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } | Remove-Item -Force -Recurse
if: always()
How does the cache interact with npm ci vs npm install?
npm ci is designed for CI environments: it removes node_modules and installs exactly what’s in package-lock.json. It respects the global cache directory (npm config get cache) and will pull already‑cached tarballs instead of hitting the registry.
npm install, on the other hand, can modify package-lock.json if versions drift, which defeats reproducibility. In my pipelines I always use npm ci because:
- It guarantees a clean slate – no leftover artifacts from previous runs.
- It’s faster when the cache is warm because the tarballs are already present.
- It fails fast if the lockfile is out of sync with
package.json– a useful safety net.
If you have a monorepo that uses npm install for workspaces, you can still benefit from the cache; just remember that the workspace resolution writes additional metadata to the cache, so the first run after a workspace change may be slower.
Pro Tip: Use a project‑local npm cache for ultra‑fast builds
Pro Tip: Set
npm config set cache .npm --global falsein apreinstallscript. This tells npm to keep the cache inside the repository (e.g.,./.npm). Combined withactions/cachepointing at./.npm, you avoid the indirection of%APPDATA%and get deterministic cache restores even if the runner user changes.
// package.json
{
"scripts": {
"preinstall": "npm config set cache .npm --global false"
}
}
The downside is that the cache folder becomes part of your repo’s .gitignore and can grow large, so you may want a separate cleanup job.
Pro Tip: Parallelize cache restoration for multi‑stage pipelines
Pro Tip: If you have a separate
buildjob that needs the same npm cache, declare the cache as an output of the first job and pass it vianeedsto subsequent jobs. This avoids each job downloading the same tarballs.
jobs:
install-deps:
runs-on: self-hosted
outputs:
cache-key: ${{ steps.cache-npm.outputs.cache-primary-key }}
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache npm
id: cache-npm
uses: actions/cache@v3
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node20-npm-${{ hashFiles('package-lock.json') }}
- run: npm ci
test:
runs-on: self-hosted
needs: install-deps
steps:
- uses: actions/checkout@v4
- name: Restore cache using key from previous job
uses: actions/cache@v3
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ needs.install-deps.outputs.cache-key }}
- run: npm test
FAQ
How do I find the correct npm cache path on a Windows self‑hosted runner?
Run npm config get cache inside a PowerShell step and capture the output with ::set-output. The returned value is the absolute Windows path you should feed to actions/cache.
Can I use the built‑in cache: 'npm' option of setup-node on Windows?
The flag works on Linux/macOS but is a no‑op on Windows because the underlying action cannot resolve the Windows user profile path at that stage. You still need a manual cache step as shown.
What happens if the cache key collides with a previous run that used a different Node version?
Including the Node major version (node20) in the key prevents collisions. If you forget, npm may try to use binaries compiled for the wrong runtime, resulting in obscure errors like ERR_DLOPEN_FAILED.
Is it safe to store the npm cache on the same drive as the build workspace?
Yes, as long as you have enough free space. Keeping them together simplifies cleanup and ensures the cache survives runner reboots. Just monitor disk usage; npm caches can exceed 5 GB for large monorepos.
How can I purge old caches that are no longer needed?
GitHub does not provide a direct UI for cache deletion, but you can invalidate them by changing the cache key (e.g., append a version suffix). Alternatively, use the GitHub REST API to list and delete caches programmatically.
Conclusion
Configuring GitHub Actions to cache npm dependencies on a Windows self‑hosted runner boils down to three practical steps:
- Discover the exact npm cache directory using
npm config get cache. - Create a robust cache key that includes the OS, Node.js 20 version, and a hash of
package-lock.json(or all lockfiles in a monorepo). - Restore and save the cache with
actions/cache, making sure the path uses Windows‑style separators and is referenced via an output variable.
By following the pattern above, my CI time dropped from ~20 minutes to under 3 minutes, and the pipeline became resilient to flaky network conditions on the self‑hosted VM. The extra bits—project‑local caches, multi‑job sharing, and periodic pruning—are optional but often turn a good setup into a production‑grade solution.
If you’re running Node.js 20 on Windows self‑hosted runners, give this cache configuration a try. You’ll see immediate speed gains and a more predictable CI experience.
Stay tuned for more deep‑dive posts on scaling CI/CD, Windows tooling, and modern Node.js workflows.

