TechEarl

Node.js in GitHub Actions: setup-node and the Version Matrix

How to configure GitHub Actions setup-node properly: the minimal workflow, dependency caching, a version matrix across LTS lines, reading .nvmrc so CI matches local dev, private registry auth, and the version-drift and cache mistakes that bite in real pipelines.

Ishan Karunaratne⏱️ 12 min readUpdated
Share thisCopied
GitHub Actions setup-node workflow running a Node.js version matrix across multiple LTS releases in CI

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

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:

yaml
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 test

Four 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:

yaml
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 wildcard

For 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.

yaml
- uses: actions/setup-node@v6
  with:
    node-version: 22
    cache: npm

Since 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:

yaml
- uses: actions/setup-node@v6
  with:
    node-version: 22
    cache: pnpm
    cache-dependency-path: packages/api/pnpm-lock.yaml

One 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:

yaml
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 test

A few notes from running this in anger:

  • fail-fast: false lets 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 want false.
  • 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:
yaml
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:

yaml
- uses: actions/setup-node@v6
  with:
    node-version-file: .nvmrc
    cache: npm

Now 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":

yaml
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.

yaml
# 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-lockfile

For 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:

yaml
- 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:

InputExampleWhat it does
node-version22, 22.11.0, lts/*The Node version (or range) to install. Pin this.
node-version-file.nvmrc, package.jsonRead the version from a file instead of inlining it.
cachenpm, yarn, pnpmRestore the package manager's download cache, keyed on the lockfile.
cache-dependency-pathpackages/api/pnpm-lock.yamlWhere the lockfile lives, for monorepos or non-root setups.
package-manager-cachefalseDisable the automatic npm cache (v5+) when package.json has a packageManager field.
registry-urlhttps://npm.pkg.github.comWrite an authenticated .npmrc for a private/scoped registry.
scope@my-orgThe npm scope tied to the registry above.
architecturex64, arm64Target architecture (rarely needed; defaults to the runner's).
check-latesttrueAlways 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

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsNode.jsJavaScriptGitHub ActionsCI/CDsetup-nodeDevOpsnpmVersion Management

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

nvm vs fnm vs Volta: Which Node Version Manager?

nvm vs fnm vs Volta, compared by speed, auto-switching, platform support, and pinning model. Which Node version manager to pick in 2026, with install commands, the .nvmrc vs package.json question, and honest caveats from running all three.

How to Check Your Node.js and npm Version

Run node --version and npm --version to see what you have installed. This covers every way to check Node and npm, finding which install is on PATH, reading the version inside a script, and the gotchas with version managers and multiple installs.