Level 6 — NVM and Node.js
Your Nuxt frontend and NestJS backend both run on Node.js. This chapter installs it correctly, which mostly means avoiding the several ways of installing it that break CI/CD later.
What Node.js is
Node.js is a JavaScript runtime built on V8 (Chrome's engine) plus libuv, which provides the event loop and asynchronous I/O. It lets JavaScript run outside a browser, with access to files, network sockets, and processes.
The properties that matter for deployment:
| Property | Consequence |
|---|---|
| Single-threaded event loop | One CPU core per process. To use 4 cores you run 4 processes — that is what PM2 cluster mode does (Level 13). |
| Non-blocking I/O | Excellent at many concurrent connections; a single process handles thousands of open sockets. |
| CPU-bound work blocks everything | A synchronous loop, a huge JSON.parse, or bcrypt with high rounds freezes all requests in that process. |
| Memory limit per process | The V8 heap defaults to roughly 4 GB on 64-bit Node 22, but the practical limit is your server's RAM. |
| Crashes take the whole process | An unhandled promise rejection or uncaught exception exits the process. Hence a process manager. |
Why NVM
There are four ways to install Node on Ubuntu. Only one is right for this setup.
| Method | Version available | Problems |
|---|---|---|
apt install nodejs | Whatever Ubuntu packaged — often years old (18.x on 24.04) | Too old for Nuxt 4 / modern NestJS |
| NodeSource repo | Current, system-wide, /usr/bin/node | Root-owned; global npm installs need sudo; switching versions means reinstalling |
| NVM | Any version, per-user, switchable | PATH must be set up correctly for non-interactive shells (solved below) |
| Docker | Pinned in the image | Great — but then the whole app is containerised (Level 11) |
NVM (Node Version Manager) is a shell function that installs Node versions into ~/.nvm/versions/node/ and switches PATH between them.
Why it wins for this deployment:
- No sudo needed — everything lives in the deploy user's home.
npm i -g pm2just works. - Instant version switching — test Node 24 without touching the running app.
- Per-project versions — an
.nvmrcfile pins the version, andnvm usereads it. - Matches local development — you almost certainly use NVM or fnm on your laptop.
NVM'S ONE REAL DOWNSIDE
NVM is a shell function defined in ~/.bashrc. Non-interactive shells (ssh server "pm2 list", cron jobs, systemd units) do not load ~/.bashrc, so node is not on PATH and you get command not found.
This is the single most common CI/CD deployment failure. The fix is in this chapter and again in Level 18. It is entirely solvable — but you must solve it deliberately.
THE ALTERNATIVE: NodeSource
If you want to avoid the PATH problem entirely and do not need multiple versions, NodeSource is a legitimate choice:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejsNode lands in /usr/bin/node, which is on every shell's default PATH, so CI/CD "just works". The cost: global npm installs need sudo, and upgrading Node is a system-wide operation you cannot roll back quickly.
This guide uses NVM because version agility matters more in practice, and the PATH issue has a clean solution.
Installing NVM
Run as the deploy user, never as root.
# SERVER — as deploy
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bashPIPING A URL INTO BASH
curl | bash executes whatever the server returns, with your user's privileges. It is the standard installation method for NVM, but understand what you are accepting: if that URL were compromised, you would run the attacker's code as deploy.
Reduce the risk:
- Pin the version (
v0.40.1above) rather than using amasterURL — a moving target can change under you. - Read it first if you want certainty:bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh > /tmp/nvm-install.sh less /tmp/nvm-install.sh bash /tmp/nvm-install.sh - Never run an installer like this as root — the blast radius becomes the whole machine.
Check github.com/nvm-sh/nvm for the current release tag.
The installer creates ~/.nvm and appends to ~/.bashrc:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"Load it into the current shell:
# SERVER
source ~/.bashrc
nvm --version # 0.40.1If nvm: command not found, the lines were appended to a file your shell does not read. Check ~/.bashrc, ~/.profile, and ~/.bash_profile, and source the right one.
Installing Node.js
# SERVER
nvm install --lts # latest LTS
nvm use --lts
nvm alias default lts/* # make it the default for new shells
node -v # v22.x.x
npm -v # 10.x.x
which node # /home/deploy/.nvm/versions/node/v22.x.x/bin/nodenvm alias default IS NOT OPTIONAL
Without it, a new shell starts with no Node on PATH. Your app works until the next reboot or reconnect, then mysteriously does not. Set the default immediately after installing.
LTS vs Current
Node has two release lines:
| LTS (even-numbered: 20, 22, 24) | Current (odd-numbered: 21, 23) | |
|---|---|---|
| Supported for | 30 months | 6 months |
| Receives | Security + bug fixes for 30 months | Fixes only until the next release |
| Breaking changes | No | Yes |
| Production | ✅ Yes | ❌ Never |
The lifecycle: a version becomes Current in April/October, an even version becomes Active LTS in October, then Maintenance LTS (security fixes only) for the final 12 months, then End of Life — no more security patches at all.
KNOW YOUR END-OF-LIFE DATE
Running an EOL Node version means known, unpatched vulnerabilities in your runtime. Check nodejs.org/en/about/previous-releases and put the EOL date of your version in your calendar. Plan the upgrade a couple of months before, not after.
Pin the version per project
# SERVER — in your project root
echo "22" > .nvmrcCommit .nvmrc. Then anywhere:
nvm use # reads .nvmrc
nvm install # installs the version from .nvmrc if missingGitHub Actions reads it too:
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'PIN THE SAME VERSION EVERYWHERE
Your laptop, CI, and the server should run the same Node major version. Version drift produces the worst class of bug: works locally, works in CI, fails in production. .nvmrc + node-version-file in CI + nvm use on the server keeps all three aligned from one file.
Also add to package.json so npm/pnpm warns on mismatch:
{
"engines": { "node": ">=22.0.0 <23.0.0" }
}Managing versions
nvm ls # installed versions, with the active one marked
nvm ls-remote --lts # available LTS releases
nvm install 24 # install another major
nvm use 24 # switch in this shell only
nvm alias default 22 # change the default for new shells
nvm uninstall 20 # remove a version
nvm current # active versionSWITCHING NODE VERSIONS ORPHANS GLOBAL PACKAGES
Each Node version has its own node_modules for globals. After nvm install 24 && nvm use 24, pm2 is gone — it was installed under 22.
nvm install 24 --reinstall-packages-from=22And critically: PM2 processes started under Node 22 keep running Node 22. Switching your shell's version does not migrate them. After a Node upgrade you must pm2 delete all and restart from the ecosystem file, plus re-run pm2 unstartup / pm2 startup so the boot script points at the new Node path. Skipping this means a reboot resurrects your app on a Node version that no longer exists. (Level 13)
npm and pnpm
npm ships with Node. pnpm is a faster, disk-efficient alternative and what this stack uses.
| npm | pnpm | |
|---|---|---|
node_modules layout | Flattened copies | Symlinks into a global content-addressed store |
| Disk usage | Every project has its own full copy | Each package version stored once on disk |
| Install speed | Baseline | Typically 2–3× faster |
| Strictness | Permissive — you can import undeclared dependencies ("phantom deps") | Strict — undeclared imports fail |
| Monorepo support | Workspaces | Workspaces, generally better |
| Lockfile | package-lock.json | pnpm-lock.yaml |
PNPM'S STRICTNESS IS A FEATURE
npm's flat node_modules lets you import lodash even when it is only a transitive dependency. It works until that transitive dependency is removed by an unrelated upgrade, and then production breaks. pnpm fails immediately at install time instead. Painful the first week, valuable forever after.
Installing pnpm via Corepack
Corepack ships with Node and manages package-manager versions. It is the correct way to install pnpm.
# SERVER
corepack enable
corepack prepare pnpm@latest --activate
pnpm -vCOREPACK CHANGED IN NODE 22+
Corepack is still bundled but no longer auto-enabled, and there are plans to unbundle it in future majors. If corepack: command not found, install pnpm directly:
npm install -g pnpm
# or the standalone installer:
curl -fsSL https://get.pnpm.io/install.sh | sh -Pin the package manager version
In package.json:
{
"packageManager": "pnpm@9.12.0"
}Corepack reads this and uses exactly that version — on your laptop, in CI, and on the server. This eliminates "the lockfile format changed" surprises, which are otherwise a recurring source of failed deploys.
corepack install # installs the version from packageManagerGlobal packages
# SERVER
npm install -g pm2
pm2 -v
npm list -g --depth=0 # what is installed globallyWith NVM these land in ~/.nvm/versions/node/v22.x.x/lib/node_modules — no sudo required.
NEVER USE sudo npm install -g WITH NVM
It writes root-owned files into your user's NVM directory. Subsequent non-sudo installs then fail with EACCES, and the usual "fix" people find is sudo chmod -R 777, which is worse. If you ever do it by accident:
sudo chown -R $(whoami) ~/.nvmKeep the global list minimal — pm2 and nothing else, ideally. Everything a project needs belongs in its devDependencies and runs via pnpm exec.
The non-interactive shell problem
This deserves its own section because it will bite you.
# LOCAL — works
ssh deploy@203.0.113.10
node -v # v22.11.0
# LOCAL — fails
ssh deploy@203.0.113.10 "node -v"
# bash: node: command not foundWhy: the first is an interactive login shell, which reads ~/.bashrc. The second is a non-interactive shell. Ubuntu's default ~/.bashrc begins with:
# If not running interactively, don't do anything
case $- in
*i*) ;;
*) return;;
esacIt returns immediately, so the NVM lines further down never execute.
Solution 1 — source NVM explicitly in the command (most reliable)
ssh deploy@203.0.113.10 "export NVM_DIR=\$HOME/.nvm && . \$NVM_DIR/nvm.sh && node -v"Verbose but bulletproof, and it is what you will do in CI/CD.
Solution 2 — put NVM setup above the interactive guard
Edit ~/.bashrc and move the NVM block to the very top, before the case $- in check:
# ~/.bashrc — TOP OF FILE
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# If not running interactively, don't do anything
case $- in
*i*) ;;
*) return;;
esac
# ... rest of the fileNow ssh server "node -v" works. Note this runs nvm.sh (a few hundred milliseconds) for every non-interactive command, including every scp and rsync.
DO NOT PRINT ANYTHING FROM .bashrc
Any output from a non-interactive .bashrc corrupts scp, rsync, and git push over SSH, producing confusing "protocol error" messages. Never add echo statements to .bashrc, and guard anything chatty with the interactive check.
Solution 3 — add the bin directory to PATH directly (fastest)
# SERVER — append to ~/.bashrc top, and also ~/.profile
export PATH="$HOME/.nvm/versions/node/v22.11.0/bin:$PATH"No NVM function loading at all, so it is instant. The cost: the path is hardcoded, so it silently breaks when you upgrade Node. If you use this, put it in your upgrade checklist.
Solution 4 — symlink into a system path (best for CI/CD)
# SERVER
sudo ln -sf "$(which node)" /usr/local/bin/node
sudo ln -sf "$(which npm)" /usr/local/bin/npm
sudo ln -sf "$(which pnpm)" /usr/local/bin/pnpm
sudo ln -sf "$(which pm2)" /usr/local/bin/pm2/usr/local/bin is on the default PATH for every shell, including cron and systemd. ssh server "pm2 list" now works with no shell configuration at all.
RECOMMENDED COMBINATION
Use Solution 2 (NVM at the top of .bashrc) for interactive convenience and Solution 4 (symlinks) for robustness in CI/CD and cron.
Remember to re-run the symlink commands after every Node upgrade — put them in a small script:
# ~/bin/relink-node.sh
#!/usr/bin/env bash
set -euo pipefail
source "$HOME/.nvm/nvm.sh"
for b in node npm npx pnpm pm2; do
p="$(command -v "$b" || true)"
[ -n "$p" ] && sudo ln -sf "$p" "/usr/local/bin/$b" && echo "linked $b -> $p"
doneVerify from your laptop:
# LOCAL — all of these must work
ssh deploy@203.0.113.10 "node -v"
ssh deploy@203.0.113.10 "pnpm -v"
ssh deploy@203.0.113.10 "pm2 -v"
ssh deploy@203.0.113.10 "which node && which pnpm && which pm2"If these fail, your CI/CD deployment will fail for exactly the same reason. Fix it now, not at 2 a.m.
Production considerations
Build on the server, or in CI?
| Build on server | Build in CI, ship artifact | |
|---|---|---|
| Server RAM needed | High — Nuxt builds peak >2 GB | Low |
| Downtime during deploy | Longer (build time is in the window) | Shorter |
| Devtools on the server | Needed | Not needed |
| Reproducibility | Depends on server state | Same artifact everywhere |
| Rollback | Rebuild the old commit | Redeploy the previous artifact — instant |
| Complexity | Lower | Slightly higher |
THE PRAGMATIC ANSWER
Start by building on the server. It is simpler, and one command (pnpm build) does everything. It works fine on a 4 GB VPS.
Move to CI-built artifacts when you hit OOM during builds, deploy frequently enough that build time hurts, or need instant rollback. This is the natural second stage, and Level 20 covers it.
If you build on a 2 GB server and hit OOM:
# Give Node more heap headroom (still bounded by RAM + swap)
NODE_OPTIONS="--max-old-space-size=1536" pnpm buildProduction install flags
# SERVER — CI/deployment install
pnpm install --frozen-lockfile| Flag | Effect |
|---|---|
--frozen-lockfile | Fail if pnpm-lock.yaml does not match package.json. Always use in CI/production — it guarantees the exact dependency tree you tested. |
--prod | Skip devDependencies. Use after building, not before — you need devDeps to build. |
--offline | Use only the local store. Good for reproducibility. |
The correct order on a server:
pnpm install --frozen-lockfile # all deps, including dev
pnpm build # needs devDependencies
pnpm prune --prod # then drop devDependenciesNEVER RUN A BARE pnpm install IN PRODUCTION
Without --frozen-lockfile, pnpm may resolve newer versions matching your semver ranges and silently update the lockfile. You then run code you never tested. This is also a supply-chain risk: a compromised patch release lands straight in production.
NODE_ENV
NODE_ENV=productionThis affects a great deal:
- Express/NestJS disable verbose error pages that leak stack traces
- Vue/Nuxt strip development warnings and use optimised builds
- Many libraries skip expensive runtime validation
npm installskips devDependencies (npm only; pnpm uses--prod)
Set it in your PM2 ecosystem file, not in a shell profile (Level 13).
FORGETTING NODE_ENV=production IS A SECURITY ISSUE
In development mode, an unhandled error can return a full stack trace to the client, revealing file paths, library versions, and sometimes environment values. Always verify: pm2 env 0 | grep NODE_ENV.
Node flags worth knowing
node --max-old-space-size=2048 dist/main.js # heap limit in MB
node --enable-source-maps dist/main.js # readable stack traces from compiled TSSet via NODE_OPTIONS in the PM2 ecosystem file. --enable-source-maps costs a little startup time and memory but turns dist/main.js:1:48213 into src/users/users.service.ts:42:15, which is worth it during an incident.
NEVER RUN --inspect IN PRODUCTION
node --inspect opens a debugger on port 9229 that allows arbitrary code execution and full memory access. Even bound to localhost it is one SSH tunnel away from a total compromise. If you must debug production, use --inspect=127.0.0.1:9229 briefly, tunnel in, and stop the process afterwards — never leave it in your ecosystem config.
Verify the installation
# SERVER
node -v && npm -v && pnpm -v && pm2 -v
which -a node # catch duplicate installs
node -e "console.log(process.arch, process.platform)" # x64/arm64 sanity check# LOCAL — the CI/CD readiness test
ssh deploy@203.0.113.10 "node -v && pnpm -v && pm2 -v"Production Checklist — Level 6
- [ ] NVM installed as the
deployuser, not root - [ ] Node.js LTS installed (even-numbered major)
- [ ]
nvm alias default lts/*set so new shells get Node - [ ]
.nvmrccommitted and matching CI and local development - [ ]
engines.nodeset inpackage.json - [ ] pnpm installed via Corepack, version pinned in
packageManager - [ ] PM2 installed globally without sudo
- [ ]
ssh deploy@server "node -v && pnpm -v && pm2 -v"works from my laptop - [ ] Symlinks in
/usr/local/bin(or an equivalent PATH fix) so cron and CI find the binaries - [ ] Nothing in
.bashrcprints output (would break scp/rsync) - [ ] Deployments use
pnpm install --frozen-lockfile - [ ]
NODE_ENV=productionis set for the running app and verified - [ ] I know my Node version's EOL date and it is in my calendar
- [ ] No
--inspectflag anywhere in production configuration