The minimal correct way to run Node.js in CI is the actions/setup-node action with a pinned major and dependency caching turned on. Almost every other detail (matrix testing, reading .nvmrc, private-registry auth) layers on top of this base. The mistakes I see most often are not exotic: they are an unpinned Node version that silently flips when GitHub bumps the runner default, and caching that was never enabled so every run re-downloads the entire dependency tree. Below is the full reference for github actions setup-node in 2026, from the four-line minimum to a version matrix across every supported LTS line, with the caching, pinning, and auth patterns that keep CI matching local dev.
Jump to:
- Which actions/setup-node version is current?
- The minimal setup-node workflow
- Why you must pin the Node version
- Enable dependency caching (the biggest speedup)
- The version matrix across LTS lines
- Reading .nvmrc so CI matches local dev
- yarn and pnpm instead of npm
- Private registries and auth tokens
- setup-node inputs reference
- Common mistakes
- FAQ
Which actions/setup-node version is current?
The current major of actions/setup-node is v6, and the version you should pin is actions/setup-node@v6. The latest release in that line is v6.4.0 (April 2026); v6 supports the Node majors in use today, including Node 24. Here is the recent release history:
| Release | Date | Notes |
|---|---|---|
| v6.4.0 | Apr 2026 | Latest release in the v6 line |
| v6.0.0 | Oct 2025 | First v6 major |
| v5.0.0 | Sep 2025 | Previous major |
Pin the major, not a moving target: @v6 tracks the latest v6.x and picks up patches automatically, while @main pulls whatever is on the action's default branch (which can break your pipeline with no change to your code), and an exact tag like @v6.4.0 goes stale and stops getting fixes. For stricter supply-chain hygiene you can pin to a commit SHA, but @v6 is the right default for most repos. The same logic applies to actions/checkout@v5: pin the major.
The minimal setup-node workflow
Drop this in .github/workflows/ci.yml. It checks out the repo, installs a pinned Node major, restores the npm cache, and runs install plus tests:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm testFour things matter here. actions/setup-node@v6 is the current major of the action (pin the action major, not a floating @main). node-version: 22 pins the Node major so CI does not move under you. cache: npm restores the dependency cache keyed on the lockfile. And npm ci, not npm install, is what you want in CI: it installs strictly from package-lock.json and errors if the lockfile and package.json disagree, which is exactly the determinism you want in a pipeline.
Why you must pin the Node version
If you omit node-version entirely, setup-node falls back to whatever Node is preinstalled on the runner image, and that version changes when GitHub updates ubuntu-latest. A build that passed last week can fail today with no change to your code, because the runner moved from one Node major to the next. I have lost an afternoon to exactly this. Always pin.
You have a few ways to express the pin, from loosest to strictest:
node-version: 22 # latest 22.x at run time (recommended for most apps)
node-version: 22.11.0 # exact patch, fully reproducible
node-version: lts/* # latest LTS line, whichever that currently is
node-version: 20.x # same as "20", explicit minor wildcardFor an application, node-version: 22 (a bare major) is the sweet spot: you get security patches automatically but never an accidental major bump. For a library you want maximum reproducibility on, pin the exact patch. lts/* is convenient but it will roll to the next LTS line every April when a new even-numbered major goes Active, so treat it the way you would treat node:lts in a Dockerfile: fine for convenience, surprising on flip-day.
Enable dependency caching (the biggest speedup)
The single most impactful line in the whole workflow is cache: npm. Without it, every CI run re-downloads your entire dependency tree from the registry. With it, setup-node restores a cache keyed on the hash of your lockfile, so an unchanged package-lock.json means a near-instant install.
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npmSince setup-node@v5 there is a wrinkle worth knowing: if your package.json declares a packageManager (or devEngines.packageManager) field set to npm, the action caches npm automatically even without cache: npm. That is convenient, but it also means a workflow that looks cache-free may still be writing a cache. Set package-manager-cache: false to turn it off, which is what you want on a privileged or secrets-bearing job where you would rather not persist a cache at all. yarn and pnpm are never auto-cached; you still pass cache: explicitly for those.
cache accepts npm, yarn, or pnpm. By default setup-node looks for the lockfile (package-lock.json, yarn.lock, or pnpm-lock.yaml) at the repo root. In a monorepo where the lockfile lives elsewhere, point at it explicitly:
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: packages/api/pnpm-lock.yamlOne caveat worth internalising: cache here caches the package manager's download cache, not your installed node_modules. You still run npm ci on every job; the cache just makes the download step fast. That is the right tradeoff, because restoring a full node_modules across Node majors invites the stale-native-binary problems covered in the Node.js upgrade reference.
The version matrix across LTS lines
If you maintain a library, you should test against every Node line you claim to support. A matrix runs the same job in parallel across each version:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [20.x, 22.x, 24.x]
steps:
- uses: actions/checkout@v5
- name: Node ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm testA few notes from running this in anger:
fail-fast: falselets every version finish even if one fails. The default (true) cancels the whole matrix the moment one leg fails, which hides whether the bug is version-specific or universal. For a compatibility matrix you almost always wantfalse.- Test against Active LTS, Maintenance LTS, and the next LTS at minimum. As of mid-2026 that is roughly 20, 22, and 24. Drop a line from the matrix when it goes end-of-life; the Node.js release schedule is the source of truth for those dates. If you are unsure which line your project should target as its baseline, the LTS vs Current tradeoff is the deciding factor: pin an even-numbered LTS major for anything you ship, and only matrix in
currentif you are deliberately testing forward. - You can fan the matrix across operating systems too:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [20.x, 22.x]
runs-on: ${{ matrix.os }}That produces six jobs (three OSes times two Node lines). Worth it if you ship native modules or hit filesystem-path edge cases; overkill for a pure-JS library where Linux coverage is representative.
Reading .nvmrc so CI matches local dev
The cleanest way to stop CI and local dev from drifting is to keep the Node version in one file that both read. setup-node reads .nvmrc, .node-version, or the volta/engines field in package.json via node-version-file:
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npmNow your developers run nvm use (or fnm, which auto-switches on cd once you wire up its shell hook) against the same .nvmrc, and CI reads the identical file. Bump the version in one place and everything follows. This is the pattern I reach for on any app with more than one contributor; the mechanics of the pinning files themselves are in the Node version pinning guide.
You can also combine the file with a matrix for the common case "test the pinned version plus the next LTS":
strategy:
matrix:
node-version-file: ['.nvmrc']
extra: ['', '24']Honestly that gets awkward fast. If you need a real matrix, list the versions explicitly; if you need CI to match dev exactly, use node-version-file and skip the matrix. Pick one intent per workflow.
yarn and pnpm instead of npm
setup-node is package-manager-agnostic for the cache; the difference is the install command and the cache value.
# Yarn (classic or Berry)
- uses: actions/setup-node@v6
with:
node-version: 22
cache: yarn
- run: yarn install --immutable
# pnpm (enable Corepack first, or use pnpm/action-setup)
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: corepack enable
- run: pnpm install --frozen-lockfileFor pnpm there is an ordering wrinkle: if you let setup-node set up the pnpm cache, pnpm needs to exist first. The robust pattern is to run corepack enable (Corepack ships with Node 16.10+) before the install, or use the dedicated pnpm/action-setup action ahead of setup-node. The --immutable / --frozen-lockfile flags are the yarn and pnpm equivalents of npm ci: install strictly from the lockfile, fail on drift.
Private registries and auth tokens
setup-node can write an .npmrc that points at a private or scoped registry, so installs authenticate without you hand-rolling the config:
- uses: actions/setup-node@v6
with:
node-version: 22
registry-url: https://npm.pkg.github.com
scope: '@my-org'
cache: npm
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}The key detail people miss: registry-url is what makes the action generate the authenticated .npmrc, and the token has to be passed as the NODE_AUTH_TOKEN environment variable on the install step, not as an input to setup-node. For GitHub Packages the built-in secrets.GITHUB_TOKEN works; for npmjs.com private packages or a third-party registry, store a token in repository secrets and reference that instead. The same NODE_AUTH_TOKEN mechanism is also what you use on a publish step (npm publish) at the end of a release workflow.
setup-node inputs reference
The inputs you will actually reach for, and what each one does:
| Input | Example | What it does |
|---|---|---|
node-version | 22, 22.11.0, lts/* | The Node version (or range) to install. Pin this. |
node-version-file | .nvmrc, package.json | Read the version from a file instead of inlining it. |
cache | npm, yarn, pnpm | Restore the package manager's download cache, keyed on the lockfile. |
cache-dependency-path | packages/api/pnpm-lock.yaml | Where the lockfile lives, for monorepos or non-root setups. |
package-manager-cache | false | Disable the automatic npm cache (v5+) when package.json has a packageManager field. |
registry-url | https://npm.pkg.github.com | Write an authenticated .npmrc for a private/scoped registry. |
scope | @my-org | The npm scope tied to the registry above. |
architecture | x64, arm64 | Target architecture (rarely needed; defaults to the runner's). |
check-latest | true | Always resolve the newest matching version from the dist server instead of preferring the runner's cached copy. |
node-version and node-version-file are mutually exclusive; if you set both, setup-node errors. check-latest: true trades a little speed (it hits the Node dist server every run) for always getting the newest patch in your range; leave it off unless you specifically want bleeding-edge patches.
Common mistakes
No node-version at all. You inherit the runner's default Node, which moves when GitHub updates the image. Pin a major.
npm install instead of npm ci in CI. npm install can mutate the lockfile and resolve different versions than your developers have locally. npm ci installs strictly from package-lock.json and fails loudly on drift, which is what you want in a pipeline.
Caching never enabled. Adding cache: npm is one line and is the biggest single speedup most Node pipelines can get. If you skipped it, every run re-downloads everything.
Floating action ref. actions/setup-node@main (or no ref) pulls in whatever the action's default branch is today. Pin the major: @v6 for setup-node, @v5 for checkout. For stricter supply-chain hygiene, pin to a commit SHA.
Wrong cache path in a monorepo. If the lockfile is not at the repo root, the cache silently does nothing because the key never resolves. Set cache-dependency-path to the real lockfile location.
Expecting cache to restore node_modules. It does not; it caches the package manager's download store. You still run npm ci. That is by design, and it is why a Node major bump does not leave you with a stale node_modules full of binaries compiled against the old ABI, the exact condition behind a NODE_MODULE_VERSION mismatch.
Token passed as an input instead of an env var. For private registries the auth token goes in NODE_AUTH_TOKEN on the install step, not as a setup-node input. The action writes the .npmrc; the env var supplies the credential at install time.
See also
- How to Update Node.js: nvm, fnm, Volta, Direct Install: the full reference for every realistic Node upgrade route, including the CI/CD and Docker base-image angles that pair with this workflow.
- Pinning a Node version per project with .nvmrc: the local side of the
node-version-filepattern above, so dev and CI read the same pin. - How to Dockerize a Node.js App: when your CI builds a container instead of running
setup-node, this is the multi-stage Dockerfile that pins the runtime. - Node.js LTS vs Current: how to choose which majors belong in your matrix and which one to set as the project baseline.
- Fix NODE_MODULE_VERSION mismatch: what goes wrong when a cached or copied
node_modulescarries native binaries built against a different Node ABI than the one CI runs. - Catching errors from Node.js child_process: when a build step shells out from a Node script in CI, this is how to make a nonzero exit code actually fail the job instead of passing silently.
FAQ
It falls back to the Node version preinstalled on the GitHub-hosted runner image. That version changes whenever GitHub updates ubuntu-latest (or whichever runner you target), so a build can break with no code change when the runner default moves to a new major.
Always set node-version (a bare major like 22 is the usual choice) or node-version-file so CI is deterministic.
Add cache: npm to the actions/setup-node step. It restores the npm download cache keyed on your package-lock.json hash, so an unchanged lockfile means a near-instant install.
For yarn or pnpm use cache: yarn or cache: pnpm. If the lockfile is not at the repo root (a monorepo, for example), set cache-dependency-path to its real location or the cache key will never resolve.
Use a matrix: strategy.matrix.node-version: [20.x, 22.x, 24.x] and reference ${{ matrix.node-version }} in the setup-node step. GitHub runs the job in parallel once per version.
Set fail-fast: false so one failing leg does not cancel the others, otherwise you cannot tell whether a failure is version-specific. Test Active LTS, Maintenance LTS, and the next LTS line at minimum; see the Node.js release schedule for which majors those currently are.
Yes. Use node-version-file: .nvmrc instead of node-version. The action also reads .node-version and the volta or engines field in package.json.
This keeps CI and local dev on one source of truth: developers run nvm use or fnm against the same file. See the project pinning guide for the local-side mechanics. Note that node-version and node-version-file are mutually exclusive.
Use npm ci in CI. It installs strictly from package-lock.json, deletes any existing node_modules first, and errors if the lockfile and package.json have drifted, which is exactly the determinism a pipeline needs.
npm install can rewrite the lockfile and resolve different versions than your team has locally. The yarn and pnpm equivalents are yarn install --immutable and pnpm install --frozen-lockfile.
For caching, yes. setup-node can install Node without a checkout, but cache: npm needs to hash your lockfile, and that file only exists on the runner after actions/checkout has pulled the repo. Put checkout first or the cache key never resolves and the cache silently does nothing.
You also need the checkout before any npm ci / npm test step, since those read package.json and your source. The only time you can skip it is a job that just resolves a Node binary and does nothing with your code.
Set the full version: node-version: 22.11.0 installs exactly that patch and nothing else. A bare major like 22 resolves to the latest 22.x at run time, which is what you usually want for an app so you pick up security patches automatically.
Pin the exact patch only when you need a fully reproducible build (a library you are bisecting, or a runtime-specific bug you are reproducing). For everything else the bare major plus check-latest left off is the sweet spot between reproducibility and staying patched.
Sources
Authoritative references this article was fact-checked against.
- actions/setup-node (GitHub Actions) READMEgithub.com
- Building and testing Node.js (GitHub Actions docs)docs.github.com
- Node.js release schedule and LTS timeline (nodejs/Release)github.com
- Using a matrix for your jobs (GitHub Actions docs)docs.github.com
- actions/setup-node releases (current major and changelog)github.com





