Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Change Node Version: The Cross-Platform Playbook for Per-Project, Per-Shell, and Per-Deploy

Sean

Platform Writer

Jun 19, 2026
7 min read

Changing the Node version is three problems in one: the version the project expects (.nvmrc or package.json#engines), the version the local shell runs (nvm use or its equivalent), and the version the deploy uses (the Docker image or the CI config). Each one has its own fix, and most “my Node version is wrong” reports are actually two of the three being out of sync. That is why “change node version” is still a heavily searched query — the answer has three layers, and the docs cover one at a time.

This post walks all three, the right tool for each, and the one habit that keeps the three in sync without thinking about it.

Change Node version: the cross-platform playbook for per-project, per-shell, and per-deploy

Table of contents

The direct answer: three layers, three tools

# 1. Project version (committed to the repo)
echo "20.11.1" > .nvmrc

# 2. Local shell version
nvm use           # uses the .nvmrc if present, otherwise the default
nvm install 20    # installs Node 20.x and sets it as the default

# 3. Deploy version (Docker)
# In your Dockerfile:
FROM node:20.11.1-slim

The first says what the project expects. The second makes the local shell match. The third makes the deploy match. The trap is treating any one of them as the source of truth and skipping the other two.

Layer 1: the project version (.nvmrc and engines)

The project version lives in two places, and both are useful in different ways.

.nvmrc — a one-line file at the project root with the Node version:

20.11.1

The full version (major.minor.patch) is the most precise. A floating version like 20 or 20.11 is also valid and means “any 20.x.x” or “any 20.11.x.” Pin the patch version for production, allow the minor for development.

package.json#engines.node — the field npm uses to warn (or fail) when the wrong version is used:

{
  "engines": {
    "node": ">=20.11.0 <21"
  }
}

The syntax is npm’s semver range syntax. >=20.11.0 <21 means “any 20.x at or above 20.11.0.” ^20.11.0 means “any 20.x at or above 20.11.0” (npm’s ^ is the same as >= for the first non-zero digit).

The difference: .nvmrc is for the local shell. engines is for npm. Both belong in the repo. The right answer is to have both, with the same major.minor pinned, and to have CI fail if they disagree.

A subtler choice: the engines field is informational by default. npm warns but does not fail. To make it fail, add engine-strict=true to .npmrc, or pass --engine-strict to npm install. The right answer for a team is to enable this in CI and let the local install be a warning.

Layer 2: the local shell version (nvm use and friends)

The local shell version is the version node resolves to when you type node in a terminal. On macOS, on Linux, and on Windows (via WSL), the right tool is nvm (Node Version Manager).

The setup:

# Install nvm (Linux/macOS)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

# Install the version the project expects
nvm install 20.11.1

# Set the default
nvm alias default 20.11.1

The per-project use:

# From the project root (uses .nvmrc if present)
nvm use

# Or install if missing
nvm install

The shell hook that makes it automatic. Add this to ~/.zshrc or ~/.bashrc to have nvm switch Node versions whenever you cd into a project with a .nvmrc:

# nvm auto-switch on cd
autoload -U add-zsh-hook
load-nvmrc() {
  local nvmrc_path
  nvmrc_path="$(nvm_find_nvmrc)"
  if [ -n "$nvmrc_path" ]; then
    local nvmrc_node_version
    nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")
    if [ "$nvmrc_node_version" = "N/A" ]; then
      nvm install
    elif [ "$nvmrc_node_version" != "$(nvm version)" ]; then
      nvm use
    fi
  fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc

With this hook, cd into a project with .nvmrc and the Node version switches automatically. cd .. and it switches back. The shell stays in sync with the project without any active work.

Alternatives to nvm:

  • fnm (Fast Node Manager) — Rust-based, faster than nvm, same shape. The right choice for users who care about shell startup time.
  • volta — the tool from the team behind pnpm and yarn. Pins per-project versions in a similar shape, but uses a different config file (package.json#volta).
  • n — the original simple Node version manager. Works, but less actively maintained than nvm or fnm.
  • asdf — a polyglot version manager that handles Node, Python, Ruby, and others. The right choice for a team that needs to manage multiple language runtimes.

For a developer with one project, nvm is fine. For a polyglot team, asdf is the right answer. For a developer who cares about speed, fnm.

Layer 3: the deploy version (Docker and CI)

The deploy version is the version the runtime executes. In a Docker container, it is the version in the FROM line. In CI, it is the version in the actions/setup-node step (GitHub) or the equivalent for your CI provider.

Docker, the right shape:

# Pin the patch version, use the slim variant
FROM node:20.11.1-slim

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]

The patch version (20.11.1, not 20 or 20.11) is the right call. Floating versions in a Dockerfile mean the build is not reproducible — the image you built today may differ from the image you build next month. Pin the version, build the image, ship the digest.

CI, GitHub Actions:

- uses: actions/setup-node@v4
  with:
    node-version-file: '.nvmrc'
    cache: 'npm'

The node-version-file flag tells the action to read the version from .nvmrc, which means the CI version and the local version are guaranteed to agree. The cache: 'npm' flag enables npm cache, which makes installs significantly faster.

CI, GitLab:

default:
  image: node:20.11.1-slim

The same pin, in a different syntax. The image is the version.

The trap on CI: the CI provider often defaults to a much older Node version (Node 12, Node 14, Node 16 on older runners). The default is rarely the version the project expects. Always pin.

The cross-platform tool matrix

PlatformToolConfig fileSpeed
macOS (zsh)nvm or fnm.nvmrcnvm slow, fnm fast
Linuxnvm, fnm, or asdf.nvmrc or .tool-versionsdepends
Windows (native)nvm-windows or fnm.nvmrcnvm-windows slow, fnm fast
Windows (WSL)nvm (Linux) or fnm (cross-platform).nvmrcdepends
Dockernode:X.Y.Z-slimDockerfileN/A
GitHub Actionsactions/setup-node.nvmrc via node-version-fileN/A
GitLab CIimage: node:X.Y.Z-slim.gitlab-ci.ymlN/A

The right answer for most developers in 2026 is fnm on macOS or Linux. The right answer for a team is whatever is in the project’s engines field plus a .nvmrc that matches. The right answer for a deploy is the pinned Docker image.

The one habit that keeps all three in sync

The habit: every time you bump the Node version in a project, change all three.

# 1. Update .nvmrc
echo "20.12.0" > .nvmrc

# 2. Update package.json#engines
# (edit the field manually or use `npm pkg set`)

# 3. Update the Dockerfile
sed -i '' 's/node:20.11.1-slim/node:20.12.0-slim/' Dockerfile

# 4. Update CI
# (GitHub Actions reads .nvmrc automatically — no change needed)
# (GitLab needs a manual edit to .gitlab-ci.yml)

# 5. Update local
nvm install
nvm use

The five-step is overkill for most bumps (CI reads from .nvmrc automatically), but the discipline is what keeps the three layers in sync. A version bump that changes .nvmrc but not the Dockerfile ships a working local environment and a broken production deploy. The reverse (Dockerfile updated, .nvmrc not) ships a broken local and a working deploy. Both are bad.

For a team, the right answer is to make the Dockerfile read from .nvmrc at build time, which removes the manual sync entirely:

# Use the ARG trick to read .nvmrc into the FROM
ARG NODE_VERSION
FROM node:${NODE_VERSION}-slim
ARG NODE_VERSION
# Build with the version from .nvmrc
docker build --build-arg NODE_VERSION=$(cat .nvmrc) -t myapp .

Now the Dockerfile and .nvmrc cannot disagree. The deploy version is a function of the project version, and the team has one source of truth.

The migration: pinning the version across a team

For a team that has been floating between Node versions, the migration to a pinned version is a one-day project:

  1. Pick the lowest common denominator. If half the team is on Node 20.10 and half on 20.12, pin to 20.11.1 (or whatever the lowest stable is).
  2. Write .nvmrc with the pinned version.
  3. Add engines.node to package.json with the same range.
  4. Update every Dockerfile to use the same pinned image.
  5. Update CI to read from .nvmrc.
  6. Add engine-strict=true to .npmrc to make the warning a hard error.
  7. Document the upgrade path in the README (one paragraph, “to bump Node, see CONTRIBUTING.md”).

The migration is friction for one day and clarity for the next year. For a small team, the friction is an afternoon. For a large team with many repos, the migration is a quarter, and the ROI is the same. The right place to start is the repo with the most pain (the one where the CI is always failing on a Node version mismatch) and to expand from there.

How this fits the rest of the stack

A Node version bump is also the right moment to model the runtime cost for the project. The new runtime may need more memory, the build may be slower, the cold start may be different, and the database traffic may look different under the new runtime. The RunxBuild hosting calculator is the quick way to model that — pick the runtime size, the memory tier, the database, the build minutes, and the traffic, and the calculator shows what the runtime upgrade will actually cost in production, not just in local benchmarks.

Useful related references:

FAQ

How do I change the Node version in a project?

Create a .nvmrc file with the version (20.11.1), and run nvm use from the project root. For a deploy, update the Docker image (node:20.11.1-slim) and any CI config that pins a version.

What is .nvmrc?

A file at the project root that contains the Node version the project expects. nvm use reads the file and switches to that version. The format is a single line with the version (20.11.1 or 20 or 20.11).

How do I make CI use the same Node version as the project?

GitHub Actions: actions/setup-node@v4 with node-version-file: '.nvmrc'. GitLab CI: pin the image: to node:X.Y.Z-slim and update it when .nvmrc changes.

Should I use nvm, fnm, or volta?

For most developers, fnm (fast, simple, same shape as nvm). For a team that needs to manage Node plus other languages, asdf. For a team that has standardized on pnpm or yarn, volta. The right answer is whichever the team will actually use consistently.

How do I pin the Node version in a Docker image?

Use the patch version in the FROM line: FROM node:20.11.1-slim. The -slim variant is smaller and faster to pull. Pin the patch version for reproducible builds.

What is the difference between engines in package.json and .nvmrc?

engines is what npm checks (informational by default, hard error with engine-strict=true). .nvmrc is what nvm uses to switch the local shell. Both belong in the repo, and both should agree on the version.

#change node version#node version manager#nvm change version#.nvmrc#node version deploy#node version ci