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.
Table of contents
- The direct answer: three layers, three tools
- Layer 1: the project version (
.nvmrcandengines) - Layer 2: the local shell version (
nvm useand friends) - Layer 3: the deploy version (Docker and CI)
- The cross-platform tool matrix
- The one habit that keeps all three in sync
- The migration: pinning the version across a team
- FAQ
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 behindpnpmandyarn. 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 thannvmorfnm.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
| Platform | Tool | Config file | Speed |
|---|---|---|---|
| macOS (zsh) | nvm or fnm | .nvmrc | nvm slow, fnm fast |
| Linux | nvm, fnm, or asdf | .nvmrc or .tool-versions | depends |
| Windows (native) | nvm-windows or fnm | .nvmrc | nvm-windows slow, fnm fast |
| Windows (WSL) | nvm (Linux) or fnm (cross-platform) | .nvmrc | depends |
| Docker | node:X.Y.Z-slim | Dockerfile | N/A |
| GitHub Actions | actions/setup-node | .nvmrc via node-version-file | N/A |
| GitLab CI | image: node:X.Y.Z-slim | .gitlab-ci.yml | N/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:
- 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).
- Write
.nvmrcwith the pinned version. - Add
engines.nodetopackage.jsonwith the same range. - Update every Dockerfile to use the same pinned image.
- Update CI to read from
.nvmrc. - Add
engine-strict=trueto.npmrcto make the warning a hard error. - 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.