Installing Node.js and npm on Ubuntu 20.04, 22.04 and 24.04
- 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
.nvmrcin 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 autoremoveComments
Corrections, additions and "this broke on my machine" reports are all welcome. You can post anonymously — no account needed.