What is it? #
Distribution repositories usually ship an old Node.js version. Installing the version your application actually needs requires either the official repository or a version manager.
For a server running one application, the NodeSource repository is the simpler choice: a system-wide install that systemd can find without extra configuration.
For a machine running several applications on different versions, nvm or fnm per user is the better fit.
Whichever you choose, pin the major version. An unplanned jump from Node 20 to 22 during a routine upgrade is not something you want to discover during a deploy.
Think of it like this #
Fitting the correct engine rather than whichever one is already in the warehouse.
The warehouse version is older, still runs, and will eventually fail to accept a part the design requires.
Simple example #
Your application requires Node 22. You install it system-wide from the official repository, verify the version, and set up the project directory owned by the application user.
Code #
# Option 1: NodeSource repository — system-wide, best for a single-app server
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node --version # v22.x
npm --version
which node # /usr/bin/node — visible to systemd without extra config
# Option 2: nvm — per user, best when versions differ between projects
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install 22
nvm use 22
nvm alias default 22
# Note: systemd does not load your shell profile, so a unit file must use
# the absolute path: /home/deploy/.nvm/versions/node/v22.x.x/bin/node
# Pin the version in the project so nobody has to guess
# package.json
# "engines": { "node": ">=22.0.0 <23.0.0" }
# .nvmrc
# 22
# Install production dependencies only, from the lockfile
cd /srv/app
npm ci --omit=dev # ci: exact lockfile versions, deletes node_modules first
# Prepare the application directory
sudo mkdir -p /srv/app
sudo chown -R appuser:appuser /srv/app
# Build as the application user, not as root
sudo -u appuser bash -c "cd /srv/app && npm ci --omit=dev && npm run build"
npm install vs npm ci
npm install may update the lockfile, resolves ranges, slower, non-deterministic
npm ci installs exactly what the lockfile says, fails if it is out of
date, deletes node_modules first — the correct choice for
servers and CI
How it works #
The NodeSource script adds the official repository and its signing key, so apt install nodejs then installs the version you asked for and keeps receiving updates within that major version.
A system-wide install puts node at /usr/bin/node, which systemd can execute directly. That matters because systemd does not load shell profiles, so a version manager installed per user requires the full path in the unit file.
nvm is excellent for development and awkward for services for exactly that reason. If you use it, use absolute paths everywhere in unit files and deployment scripts.
engines in package.json and .nvmrc document the required version, so a new machine or a new developer does not have to guess.
npm ci is the production install command. It installs precisely what the lockfile specifies and fails if the lockfile and package.json disagree, which turns a silent version drift into a build failure.
--omit=dev skips development dependencies, which reduces install time and the amount of code shipped to the server.
Building as the application user rather than root avoids leaving root-owned files in the project directory, which then break later deployments run as the deploy user.
Real-world use #
Version pinning prevents a whole class of deployment surprise. An application built and tested on Node 20 that lands on a machine running Node 22 may fail in ways that only appear under load.
Native modules are the other common issue. Packages with compiled components need build tools present, and they must be rebuilt when the Node version changes.
The node_modules directory should not be copied between machines. Building on the server, or building in CI on the same platform, avoids platform-specific binary mismatches.
For zero-downtime deployments, build into a new release directory and switch a symlink, which the deployment lesson covers.
Containers sidestep all of this by pinning the runtime in the image, which is one of the strongest arguments for the Docker track.
Common mistakes #
- Installing whatever version the distribution repository provides.
- Using nvm for a service and omitting absolute paths in the systemd unit.
- Running
npm installon the server instead ofnpm ci. - Building as root and leaving root-owned files in the project directory.
- Copying node_modules between machines with different platforms.
Practice #
Install a specific Node major version on a test server, pin it in package.json and .nvmrc, and install production dependencies with npm ci as the application user. Then confirm which node binary a systemd service would use.