The .nvmrc File Is a Three-Line Contract, and Most Teams Never Sign It

Sean

Platform Writer

Jun 12, 2026
6 min read

A .nvmrc file is a single-line text file that pins the Node.js version for a project — a literal 20.11.1 (or v20.11.1, or lts/*, or 20) sitting at the project root — and it is the cheapest insurance a Node team can buy against the “works on my machine” bug. The naive answer is node -v > .nvmrc followed by the official nvm shell hook from the README. The working answer is what the file actually does (and does not do), why the autoload shell hook is the most-upvoted line of code on GitHub that nobody should copy-paste, the four other version managers that handle this better, and the three places — Docker, CI, and the deploy platform — where the .nvmrc quietly becomes load-bearing.

The reason .nvmrc is its own question and not just “nvm” or “Node version manager” is that the file is the part that travels. The file goes into git, the file goes into the team’s laptops, the file goes into the CI runner, the file goes into the Dockerfile. The file is the contract. The version manager is just the tool that reads it.

The .nvmrc file: a three-line contract most teams never sign

Table of contents

The short version

A .nvmrc is a one-line file at the root of a Node project that contains a Node version string. Tools that respect the file (nvm, fnm, nodenv, volta, Direnv with a hook) read it and switch the local Node binary to the requested version. The supported strings are: a major (20), a major.minor (20.11), a major.minor.patch (20.11.1), the v-prefixed equivalents (v20.11.1), and the aliases lts/*, lts/iron, lts/hydrogen, lts/jod, lts/argon, node, and system. The file is read by nvm use, fnm use, volta pin, nodenv install --skip-existing, the engines field in package.json, and the FROM node:$(cat .nvmrc) line in most production Dockerfiles. The file is not read by the operating system, by the Node binary itself, by npm, or by anything that isn’t explicitly told to look at it. The file is the contract; the contract is enforced by the tool, not by the file.

What the file actually does, and what it does not

The file is a single string. Nothing more. Tools that respect it (nvm, fnm, nodenv, volta) inspect the string, install the matching version if it is not present, and use it (i.e. update PATH and any related env vars). The tool then leaves the rest alone: it does not run npm install, it does not check the lockfile, it does not validate the project’s engines field, it does not run a build, and it does not check that the requested version is supported by the project’s dependencies.

The string is forgiving. 20, 20.11, 20.11.1, v20.11.1, and lts/iron all mean the same thing to nvm and fnm (the closest installed version wins; the latest patch if a minor or major is given; the right LTS line if the alias is given). The string is not validated against a registry, the string is not parsed as semver, and the string is not cryptographically verified. The string is whatever the developer typed when they last ran node -v > .nvmrc.

The file is also a one-way contract. The file says “this project wants Node 20.11.1.” It does not say “this project supports Node 18 and 20.” For that, the project’s engines field in package.json is the right place ("engines": { "node": ">=18 <21" }), and npm (since v8) plus most modern CI runners will refuse to install if the running Node version is outside the declared range. The .nvmrc is the pin; the engines field is the promise.

The file is, finally, project-local. There is no global .nvmrc, there is no user-wide default (although nvm has nvm alias default), and there is no system-wide default (although most Linux distributions ship a nodejs package that is its own pin). The file is the project’s pin, the file is in the project, and the file is the part that travels.

How to create the file (the three ways, ranked)

1. node -v > .nvmrc (the lazy way, the right way). The command writes the currently-running Node version to the file. The pattern is the right answer for a developer who already has the right version installed, and the pattern is the right answer for a developer who is adding a .nvmrc to an existing project. The pattern is wrong for a developer who is on a version they do not actually want to pin (e.g. they ran nvm use 18 to test something and forgot to switch back).

2. echo "20.11.1" > .nvmrc (the explicit way). The command writes a specific version. The pattern is the right answer for a developer who is starting a new project, the pattern is the right answer for a developer who knows the version they want, and the pattern is the right answer for a project that needs to be reproducible across teams.

3. echo "lts/*" > .nvmrc (the “trust the LTS line” way). The command writes the LTS alias. The pattern is the right answer for a project that wants to track the current LTS line, the pattern is the right answer for a long-lived project that upgrades Node every 18 months, and the pattern is the wrong answer for a project that needs bit-for-bit reproducibility (a specific version is better than an alias here).

The three are the floor. There is also volta pin [email protected] (writes a package.json field instead of a .nvmrc, the Volta way), fnm install --save (writes a .tool-versions file instead of a .nvmrc, the asdf way), and a manual cat .nvmrc to read the file before switching.

Why the autoload shell hook is overrated

The most-upvoted answer on the canonical Stack Overflow question is the “autoload” shell hook: a 30-line bash (or zsh) function that runs on every cd and checks whether a .nvmrc exists, whether the requested version is installed, and whether the running version matches. The hook is copy-pasted into thousands of .zshrc files every year. The hook is also a 200ms tax on every cd, a 200ms tax that scales with shell startup, and a 200ms tax that nobody measures until they hit it.

The honest take is that the hook is the wrong default. A team that wants auto-switching should be using fnm (which is a single Rust binary, not a 4000-line bash function), volta (which hooks into Node’s binary directly, no shell hook needed), or nodenv (which uses shim executables and changes nothing on cd, only on node invocation). All three are faster, all three are more reliable, and all three are the right answer for a team that is paying the autoload tax on every shell session.

The honest take is also that the autoload hook is fine for a solo developer who rarely cds and is willing to pay the tax. The honest take is not that the hook is a good default. The default should be fnm use (or volta, or nodenv), and the autoload hook is the fallback for the developer who has not yet switched.

The four other tools that handle this better

fnm (Fast Node Manager). A single Rust binary that reads .nvmrc, .node-version, and .tool-versions, auto-switches on cd (with a real shell hook, but a fast one), supports lts/* aliases, and is the right answer for a developer who wants the .nvmrc behavior without the nvm tax. fnm is the closest drop-in replacement for nvm and the right answer for a team that is willing to re-install one tool.

volta. A Rust tool that pins Node and npm and pnpm and yarn in the project’s package.json (the volta field), auto-switches by shimming the Node binary itself (no shell hook), and is the right answer for a team that wants a single, declarative, version-locked Node toolchain. The Volta model is opinionated (the tool, not the file) and the right answer for a team that is willing to commit to it.

nodenv. A rbenv-style shim-based version manager that uses a .node-version file (a sibling standard, not .nvmrc), switches on node invocation (not on cd), and is the right answer for a developer who is coming from the Ruby world and wants a similar mental model. nodenv does not read .nvmrc by default, but nodenv-nvmrc adds the bridge.

asdf (with the nodejs plugin). A polyglot version manager that reads .tool-versions (not .nvmrc), supports Node, Python, Ruby, Go, Rust, and a dozen other tools, and is the right answer for a team that has a polyglot codebase and wants one tool to manage all of it. asdf is heavier than fnm or volta, and asdf is the right answer for the team that has outgrown the single-language tool.

The four are the floor. There is also n (the original minimalist), nodeenv (for Python shops that need Node), and Docker (the most honest version manager: the version is whatever the image says it is).

The three places the .nvmrc becomes load-bearing

1. The Docker image. The FROM node:20.11.1-alpine line in a Dockerfile is, in spirit, a .nvmrc that the developer can never get wrong. The pattern is the right answer for a team that wants to guarantee the runtime version, the pattern is the right answer for a team that is shipping a container, and the pattern is the right answer for a team that is tired of “but it works in the container” being a different version from “but it works on my laptop.” The honest version is FROM node:$(cat .nvmrc)-alpine in the Dockerfile, which keeps the Dockerfile honest with the .nvmrc. The pattern is the right answer for a team that wants the file to be the single source of truth.

2. The CI runner. A GitHub Actions matrix that runs actions/setup-node@v4 with node-version-file: '.nvmrc' is the same contract, written in CI YAML. The pattern is the right answer for a team that wants the CI to match the developer laptops, the pattern is the right answer for a team that is tired of “passes locally, fails in CI,” and the pattern is the right answer for a team that wants one place to change the version. GitLab CI and CircleCI have the same pattern, and the pattern is the right answer for any team that has a CI runner.

3. The deploy platform. A platform that reads the .nvmrc and uses the version in the build step (or, better, uses a node:20.11.1-alpine base image that matches the .nvmrc) is a platform that makes the .nvmrc the contract for production. The pattern is the right answer for a team that does not want the deploy to use a different version than the developer laptops, the pattern is the right answer for a team that is tired of “passes in CI, fails in production,” and the pattern is the right answer for a team that wants the same version everywhere.

The three are the floor. There is also the engines field in package.json (which npm enforces, but only since v8 and only for npm install), the .nvmrc in a monorepo (which the package manager reads to decide which Node to use for a given package), and the .node-version in a Docker context (which some tools read instead of .nvmrc).

The seven mistakes that quietly turn the file into noise

A short, opinionated list of mistakes that have actually broken real .nvmrc-driven workflows. None of them are dramatic. They are the boring ones.

Committing the file with system as the version. A .nvmrc that says system is a .nvmrc that says “use whatever Node is on the system.” The pattern is the right answer for a developer who is testing a system install, and the pattern is the wrong answer for a project that wants a contract. The fix is to overwrite the file with a specific version.

Committing the file with lts/* and then upgrading Node on a Friday afternoon. An lts/* .nvmrc is a .nvmrc that says “use the current LTS line.” The pattern is the right answer for a project that wants to track the LTS line, the pattern is the wrong answer for a project that wants bit-for-bit reproducibility, and the pattern is the lever that turns “the build is green” into “the build is red on someone’s laptop because their LTS is now a different version.” The fix is to pin a specific version (or to upgrade deliberately and commit the new pin).

The file says v20.11.1 but the developer is on v20.10.0. A .nvmrc that pins a version the developer does not have installed is a .nvmrc that fails silently. The fix is nvm install (or the equivalent for the tool), and the fix is the lever that turns “I am on the wrong version” into “I am on the right version.”

The file is at the wrong level in a monorepo. A .nvmrc in the monorepo root is the version for the monorepo; a .nvmrc in a package is the version for the package. The pattern is the right answer for a monorepo that has a single Node version, the pattern is the wrong answer for a monorepo that has different packages on different Node versions, and the pattern is the lever that turns “the wrong package got the wrong Node” into “the right package got the right Node.” The fix is to put the file at the level the tool respects (most tools respect the nearest .nvmrc going up from the cwd).

The team uses nvm-windows on Windows but the .nvmrc is committed by a teammate on macOS. A .nvmrc that says 20.11.1 works on every platform; a .nvmrc that says lts/iron works on every platform; a .nvmrc that says a path or a shell-specific alias does not. The pattern is the right answer for a cross-platform team, the pattern is the wrong answer for a team that is committing platform-specific strings, and the fix is to stick to version numbers and aliases.

The CI runner ignores the file because the YAML is hard-coded. A node-version: 20.11.1 in a GitHub Actions YAML is a node-version: 20.11.1 that does not respect the .nvmrc. The fix is node-version-file: '.nvmrc', and the fix is the lever that turns “the CI is on a different version than the developer laptops” into “the CI is on the same version as the developer laptops.”

The deploy platform builds with a different image than the .nvmrc. A Dockerfile that says FROM node:18-alpine while the .nvmrc says 20.11.1 is a Dockerfile that is going to deploy a different Node than the developer tested. The fix is to make the Dockerfile read the .nvmrc (or to use a node:20.11.1-alpine image that matches the .nvmrc), and the fix is the lever that turns “the deploy is on the wrong version” into “the deploy is on the right version.”

How this fits the rest of the deploy

A .nvmrc rarely lives in isolation. The file is usually part of a stack (a Dockerfile, a CI pipeline, a deploy platform, a managed database) that runs the Node service the project ships. The platform that handles the file should make the rest of the stack feel like part of the same conversation.

The services layer is the part of the platform that runs the long-lived Node API the project ships. The static layer is the part that hosts the dashboard the developer uses to inspect deploys. The environment variables are the part that holds the secrets the API needs to talk to the database. The .nvmrc is the pin that says “this is the Node version”; the platform is what enforces it.

A Node project on a platform where the build, the runtime, the database, and the deploy logs are all in the same place is a project the team is going to be able to operate. A Node project on a platform where each piece is in a different console is a project the team is going to spend the first hour just opening the right tab.

For a team that wants to see the full cost of the project before it commits, the RunxBuild hosting calculator shows the line items together. The service, the database, the storage, the worker, the bandwidth — each one is a separate number, and the team’s mental model for the platform is the sum of those numbers.

FAQ

What is a .nvmrc file?

A .nvmrc is a one-line text file at the root of a Node project that contains a Node version string (e.g. 20.11.1, lts/*, or 20). Tools that respect the file (nvm, fnm, nodenv, volta) read it and switch the local Node binary to the requested version. The file is the cheapest way to pin the Node version for a project and the cheapest way to end the “works on my machine” debate.

How do I create a .nvmrc file?

Run node -v > .nvmrc from the project root to write the currently-running Node version, or run echo "20.11.1" > .nvmrc to write a specific version, or run echo "lts/*" > .nvmrc to write the current LTS alias. Commit the file to git so the rest of the team (and the CI) sees it.

Does .nvmrc work on Windows?

Yes, the file format is the same on every platform. The nvm port for Windows (nvm-windows) does not read .nvmrc by default as of this writing, but fnm, volta, and nodenv do. The honest answer for a cross-platform team is to use fnm (or volta) instead of nvm-windows so the file works the same on every developer’s machine.

What’s the difference between .nvmrc and .node-version?

.nvmrc is the file nvm reads. .node-version is the file nodenv and volta read. The formats are the same (a single version string), and most modern tools (fnm, volta) read both. The honest answer for a team that wants maximum portability is to commit both files (with the same content) and let the tool decide which one to read.

Should I use lts/* or a specific version in .nvmrc?

Use a specific version (20.11.1) for projects that need bit-for-bit reproducibility. Use lts/* for projects that want to track the current LTS line and are willing to upgrade Node every 18 months. The honest answer for most teams is a specific version, and the honest answer for long-lived projects is a specific version of the current LTS (lts/iron, lts/jod).

How does the .nvmrc interact with package.json engines?

The .nvmrc is the pin (the file that says “use this version”). The engines field in package.json is the promise (the field that says “this project supports these versions”). The two are complementary: the .nvmrc enforces the version locally and in CI, the engines field is enforced by npm (since v8) and by most modern CI runners.

How do I make CI respect the .nvmrc?

In GitHub Actions, use actions/setup-node@v4 with node-version-file: '.nvmrc'. In GitLab CI, use the node image with the version from the file (image: node:$(cat .nvmrc)). In CircleCI, use the cimg/node:lts image or a custom Docker image built from the file. The pattern is the same on every platform: read the file, use the version.

What happens if .nvmrc is missing?

If the file is missing, the tool does not switch versions. nvm and fnm will use the default version (nvm alias default, which is usually whatever you installed last), and volta will use the version in package.json (or the system default). The pattern is the right answer for a developer who wants the system default, and the pattern is the wrong answer for a project that wants a contract.