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:
- 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
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.
- 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.
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.
FAQ
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





