ply.wtf
IT & InfrastructureStep-by-step guide

Installing Node.js and npm on Ubuntu 20.04, 22.04 and 24.04

8 min read
  • ubuntu
  • nodejs
  • npm
  • nvm
  • linux

Ubuntu ships Node.js in its repositories. You almost certainly don’t want it.

Why not just apt install nodejs

Because of what you actually get:

Ubuntu release apt gives you Released
20.04 LTS (focal) Node 10.19 2018
22.04 LTS (jammy) Node 12.22 2019
24.04 LTS (noble) Node 18.19 2022

Distribution packages freeze at release and only receive security backports. Node 10 and 12 have been end-of-life for years — no security fixes at all from upstream, and a large slice of npm refuses to install against them.

There is a second problem the version number hides: one system-wide Node. The moment you have a legacy app on 18 and a new one on 22 on the same box, a single global install stops working.

So: install something that lets you pick the version, and switch it per project.

Everything below is verified against 20.04, 22.04 and 24.04. Where they differ, I say so.

Before you start

sudo apt update
sudo apt install -y curl ca-certificates

If you are going to build native modules — anything pulling in node-gyp, such as bcrypt, sharp or better-sqlite3 — you also need a compiler:

sudo apt install -y build-essential python3

Leaving this out produces a wall of gyp ERR! on your first npm install, and it is not obvious from the error that a missing compiler is the cause.

A note on 20.04: standard support ended in April 2025. It still works, and everything here applies, but you are on Extended Security Maintenance. If this is a new machine, start on 24.04.

Option A — nvm (the default choice)

nvm is a shell function that keeps every Node version in your home directory and switches between them by rewriting PATH. Nothing is installed system-wide, nothing needs sudo.

Check the releases page for the current tag, then:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

If piping a script from the internet straight into bash makes you uncomfortable — it reasonably might — download it and read it first:

curl -o /tmp/nvm-install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh
less /tmp/nvm-install.sh
bash /tmp/nvm-install.sh

The installer appends its loader to your shell profile. Reload it:

source ~/.bashrc     # or ~/.zshrc if you use zsh

Confirm it took:

command -v nvm       # should print: nvm

If that prints nothing, the loader did not land in the file your shell actually reads. On Ubuntu, a non-login interactive shell reads ~/.bashrc; a login shell reads ~/.profile or ~/.bash_profile. Check that these lines exist in the right one:

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"

Installing and switching versions

nvm install --lts          # latest LTS
nvm install 22             # a specific major
nvm install 20.18.0        # an exact version

nvm ls                     # what is installed
nvm ls-remote --lts        # what is available

nvm use 22                 # switch, current shell only
nvm alias default 22       # what new shells get

nvm use affects only the current shell. Open a new terminal and you are back to the default — this surprises people constantly. nvm alias default is what makes a choice stick.

Per-project versions

Drop a .nvmrc in the repository root:

echo "22" > .nvmrc

Then nvm use with no argument reads it. To make that automatic on cd, add this to your ~/.bashrc — this is the official recommendation from the nvm README, lightly trimmed:

cdnvm() {
  command cd "$@" || return $?
  if [ -f .nvmrc ]; then
    nvm use "$(cat .nvmrc)" --silent
  fi
}
alias cd='cdnvm'

Commit the .nvmrc. It ends the “works on my machine” conversation before it starts, and CI systems read it too.

Option B — fnm (same idea, much faster)

nvm’s one real weakness is startup cost: it is a large shell script sourced by every new shell, which adds noticeable lag. fnm is a Rust reimplementation that does the same job in a fraction of the time.

curl -fsSL https://fnm.vercel.app/install | bash
source ~/.bashrc

Add automatic switching:

eval "$(fnm env --use-on-cd)"

Day-to-day commands mirror nvm:

fnm install --lts
fnm install 22
fnm use 22
fnm default 22
fnm list

fnm reads the same .nvmrc files, so a team can mix the two freely. If your shell startup feels sluggish, this is the swap to make.

Option C — NodeSource (for servers)

On a server you often want the opposite of a per-user version manager: one Node, on the system PATH, available to systemd and to every user, upgraded by apt along with everything else.

NodeSource publishes an apt repository for exactly that.

curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
less /tmp/nodesource_setup.sh          # read before you run it as root
sudo -E bash /tmp/nodesource_setup.sh
sudo apt install -y nodejs

Swap setup_22.x for setup_20.x or setup_24.x as needed. npm comes bundled — do not install the npm apt package alongside it, that combination breaks in confusing ways.

Verify:

node --version
npm --version
which node        # /usr/bin/node

Stopping unwanted upgrades

The NodeSource repository will happily move you across minor versions on apt upgrade. On a production box, pin it:

sudo apt-mark hold nodejs

Release it when you actually intend to upgrade:

sudo apt-mark unhold nodejs

Which one should you use

  • Your laptop, several projects: nvm, or fnm if shell startup annoys you.
  • A server running one app: NodeSource. Simple, on PATH, patched by apt.
  • A server running several apps on different versions: nvm per service user, with the systemd workaround below. Or containers, which sidestep the whole question.
  • CI: whatever your runner provides, driven by the .nvmrc in the repo.

The systemd trap

This one catches everybody exactly once, so it is worth spelling out.

You install Node with nvm, your app runs perfectly from the shell, you write a systemd unit, and it dies instantly:

status=203/EXEC

or

/usr/bin/env: 'node': No such file or directory

The reason: nvm puts Node in ~/.nvm/versions/node/v22.x.x/bin, and adds that to PATH from your shell profile. systemd does not start a login shell and never reads that profile, so as far as it is concerned node does not exist.

Three ways out, best first:

1. Absolute path in the unit. Explicit, and immune to whatever the shell is doing:

[Service]
ExecStart=/home/deploy/.nvm/versions/node/v22.14.0/bin/node /srv/app/server.js

The cost is that a nvm install bumping the patch version breaks the unit until you edit it.

2. Give systemd the PATH. Slightly more forgiving:

[Service]
Environment=PATH=/home/deploy/.nvm/versions/node/v22.14.0/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=node /srv/app/server.js

3. Use NodeSource on servers. Node lands in /usr/bin/node, which is already on systemd’s default PATH, and the problem never arises. This is why option C exists.

npm without sudo

If you installed with nvm or fnm, everything already lives in your home directory and global installs need no elevation. Skip this section.

With NodeSource, npm install -g writes to /usr/lib/node_modules and fails without sudo. Running npm as root is a bad habit — install scripts from packages then execute as root — so point the global prefix at your home directory instead:

mkdir -p ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc

Now npm install -g works as your own user.

Corepack, for pnpm and yarn

Node has bundled Corepack since 16.9. It manages pnpm and yarn without a global install, and pins the version per project:

corepack enable

Then in the project:

corepack use pnpm@9

That writes a packageManager field into package.json, and from then on everyone on the team — and CI — gets exactly that version. If corepack is missing on your Node build, npm install -g corepack fills the gap.

Verifying the install

node --version
npm --version
npm doctor

A quick end-to-end check:

mkdir /tmp/node-check && cd /tmp/node-check
npm init -y
npm install is-odd
node -e "console.log(require('is-odd')(3))"   # true

Troubleshooting

nvm: command not found after installing. The loader is not in the profile your shell reads. See the nvm section above. Remember nvm is a shell function, not a binary — which nvm will never find it, use command -v nvm.

EACCES on npm install -g. You are on a system-wide Node without a user prefix. Set the prefix as shown above rather than reaching for sudo.

gyp ERR! stack Error: not found: make. Missing build-essential.

Old node still showing after installing a new one. Something earlier in PATH wins. Check with type -a node, and look for a stale /usr/bin/node from an old apt install:

sudo apt remove --purge nodejs npm
sudo apt autoremove
hash -r          # clear the shell's cached command paths

npm is slower than it should be on a fresh box. Check for an IPv6 route that times out before falling back, or a leftover corporate registry setting: npm config get registry should read https://registry.npmjs.org/.

Removing it again

# nvm
rm -rf ~/.nvm
# then delete the NVM_DIR lines from ~/.bashrc

# fnm
rm -rf ~/.local/share/fnm ~/.fnm

# NodeSource
sudo apt remove --purge nodejs
sudo rm /etc/apt/sources.list.d/nodesource.list
sudo apt autoremove

Comments

Corrections, additions and "this broke on my machine" reports are all welcome. You can post anonymously — no account needed.