# Pinning the Node.js version with pnpm, without nvm
Table of Contents
“It works on my machine.” A good share of the time, behind that sentence there is a different Node.js version: the project was born on 20, a colleague runs 24, CI uses yet another one. The classic tool for living with this is nvm, which installs multiple versions side by side and switches between them. It works, but every person on the team has to install and configure it, and remembering to type nvm use at the right moment remains a chore. If you already use pnpm as your package manager, there is an often-overlooked alternative: pnpm can manage the Node.js version as well, and it can do it per project, with no extra tools.
The problem, in short
Node.js is the environment that runs JavaScript outside the browser; every project is effectively tested and guaranteed only on certain versions. nvm (Node Version Manager) tackles the problem by installing several versions side by side: nvm install 20, nvm use 20 and the current shell switches to 20.
Its limits are familiar to daily users. nvm is a shell script that must be installed per user and hooked into the shell profile; the version switch applies to the current session, so it has to be repeated (or automated with hooks) for every folder; continuous integration pipelines and containers typically use a completely different mechanism, with a real risk of drift. That’s not a flaw of nvm: it’s the price of a tool that lives outside the project.
The alternative: a runtime declared in the project
pnpm flips the approach: the Node.js version becomes a declaration of the project, written in package.json and versioned with the code. The field is called devEngines:
{ "devEngines": { "runtime": { "name": "node", "version": "20.19.0", "onFail": "download" } }}The key part is onFail: "download": if the Node in use doesn’t match the declared one, pnpm downloads the required version on its own (once - it stays in its cache) and uses it for the project, without touching the system Node. The runtime shows up in the pnpm install output like any other dependency:
$ pnpm install# ...+ node 20.19.0From then on, everything that goes through pnpm uses that version. An example tested while writing this article, on a machine with Node 22 as the system runtime:
$ node -v # the system Nodev22.22.2
$ pnpm run check-node # a script that runs "node -v"> node -vv20.19.0 # the version declared in the project
$ pnpm node -vv20.19.0No nvm use, no shell hooks: anyone cloning the repository - a new teammate, CI, a container - gets the same Node version simply by using pnpm.
Ranges work too, and it’s locked in the lockfile
version accepts either an exact version or a semver range. With "version": "^20.11.0", in our test pnpm resolved and downloaded the latest 20.x available:
$ pnpm install# ...+ node 20.20.2The most interesting detail is that the resolved version ends up in the lockfile (pnpm-lock.yaml), like a regular dependency:
node: specifier: runtime:^20.11.0 version: runtime:20.20.2This means the whole team and CI use exactly the same Node version, not just “a compatible one”: the reproducibility is the same the lockfile already guarantees for libraries. The classic engines field, by comparison, merely warns when the version doesn’t match; devEngines with onFail: "download" fixes the problem by itself.
The limits to know
The mechanism applies to what goes through pnpm: pnpm run, pnpm exec, pnpm node, the scripts in package.json. If you type node server.js directly in the terminal, you still get the system Node. In practice this is a smaller limit than it sounds - in JavaScript projects nearly everything goes through the scripts - but it’s worth knowing: devEngines doesn’t “change the computer’s Node”, it guarantees the project runs on the right version.
A note for those coming from earlier versions: up to pnpm 10 there was a use-node-version setting (or useNodeVersion in pnpm-workspace.yaml) doing something similar. pnpm 11 removed it, together with executionEnv.nodeVersion: the supported path today is devEngines. If a project still uses those keys, pnpm 11 silently ignores them - an easy migration to forget.
What about replacing nvm system-wide?
pnpm can replace nvm as a global version manager too, via the pnpm env command:
pnpm env list 20 # which 20.x versions existpnpm env use --global 20 # install and activate the latest 20.xpnpm env use --global lts # or the latest LTSAn honest caveat: pnpm env manages the global link to Node, so it requires pnpm to be installed standalone (via the official install script or Corepack), not through npm install -g pnpm - in that case pnpm would be running inside the very Node it’s trying to replace. For the per-project usage above, this distinction doesn’t matter.
In summary
If a project already uses pnpm, pinning the Node.js version doesn’t require nvm: a devEngines block in package.json with onFail: "download" makes pnpm download and use the declared runtime - exact version or range - and locks the actually resolved one in the lockfile, identical for the whole team and CI. The limit is clear (it applies to commands going through pnpm), and anyone coming from pnpm 10 must migrate from use-node-version to devEngines. nvm remains useful as a general manager on your own machine; for a project’s reproducibility, the version declared in the repository is the more solid path.
