Skip to content

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:

PropertyConsequence
Single-threaded event loopOne CPU core per process. To use 4 cores you run 4 processes — that is what PM2 cluster mode does (Level 13).
Non-blocking I/OExcellent at many concurrent connections; a single process handles thousands of open sockets.
CPU-bound work blocks everythingA synchronous loop, a huge JSON.parse, or bcrypt with high rounds freezes all requests in that process.
Memory limit per processThe 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 processAn 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.

MethodVersion availableProblems
apt install nodejsWhatever Ubuntu packaged — often years old (18.x on 24.04)Too old for Nuxt 4 / modern NestJS
NodeSource repoCurrent, system-wide, /usr/bin/nodeRoot-owned; global npm installs need sudo; switching versions means reinstalling
NVMAny version, per-user, switchablePATH must be set up correctly for non-interactive shells (solved below)
DockerPinned in the imageGreat — 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 pm2 just works.
  • Instant version switching — test Node 24 without touching the running app.
  • Per-project versions — an .nvmrc file pins the version, and nvm use reads 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:

bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs

Node 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.

bash
# SERVER — as deploy
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

PIPING 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:

  1. Pin the version (v0.40.1 above) rather than using a master URL — a moving target can change under you.
  2. 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
  3. 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:

bash
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:

bash
# SERVER
source ~/.bashrc
nvm --version      # 0.40.1

If 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 ​

bash
# 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/node

nvm 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 for30 months6 months
ReceivesSecurity + bug fixes for 30 monthsFixes only until the next release
Breaking changesNoYes
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 ​

bash
# SERVER — in your project root
echo "22" > .nvmrc

Commit .nvmrc. Then anywhere:

bash
nvm use          # reads .nvmrc
nvm install      # installs the version from .nvmrc if missing

GitHub Actions reads it too:

yaml
- 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:

json
{
  "engines": { "node": ">=22.0.0 <23.0.0" }
}

Managing versions ​

bash
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 version

SWITCHING 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.

bash
nvm install 24 --reinstall-packages-from=22

And 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.

npmpnpm
node_modules layoutFlattened copiesSymlinks into a global content-addressed store
Disk usageEvery project has its own full copyEach package version stored once on disk
Install speedBaselineTypically 2–3× faster
StrictnessPermissive — you can import undeclared dependencies ("phantom deps")Strict — undeclared imports fail
Monorepo supportWorkspacesWorkspaces, generally better
Lockfilepackage-lock.jsonpnpm-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.

bash
# SERVER
corepack enable
corepack prepare pnpm@latest --activate
pnpm -v

COREPACK 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:

bash
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:

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.

bash
corepack install     # installs the version from packageManager

Global packages ​

bash
# SERVER
npm install -g pm2
pm2 -v
npm list -g --depth=0     # what is installed globally

With 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:

bash
sudo chown -R $(whoami) ~/.nvm

Keep 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.

bash
# 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 found

Why: the first is an interactive login shell, which reads ~/.bashrc. The second is a non-interactive shell. Ubuntu's default ~/.bashrc begins with:

bash
# If not running interactively, don't do anything
case $- in
    *i*) ;;
      *) return;;
esac

It returns immediately, so the NVM lines further down never execute.

Solution 1 — source NVM explicitly in the command (most reliable) ​

bash
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:

bash
# ~/.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 file

Now 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) ​

bash
# 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.

bash
# 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:

bash
# ~/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"
done

Verify from your laptop:

bash
# 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 serverBuild in CI, ship artifact
Server RAM neededHigh — Nuxt builds peak >2 GBLow
Downtime during deployLonger (build time is in the window)Shorter
Devtools on the serverNeededNot needed
ReproducibilityDepends on server stateSame artifact everywhere
RollbackRebuild the old commitRedeploy the previous artifact — instant
ComplexityLowerSlightly 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:

bash
# Give Node more heap headroom (still bounded by RAM + swap)
NODE_OPTIONS="--max-old-space-size=1536" pnpm build

Production install flags ​

bash
# SERVER — CI/deployment install
pnpm install --frozen-lockfile
FlagEffect
--frozen-lockfileFail if pnpm-lock.yaml does not match package.json. Always use in CI/production — it guarantees the exact dependency tree you tested.
--prodSkip devDependencies. Use after building, not before — you need devDeps to build.
--offlineUse only the local store. Good for reproducibility.

The correct order on a server:

bash
pnpm install --frozen-lockfile   # all deps, including dev
pnpm build                        # needs devDependencies
pnpm prune --prod                 # then drop devDependencies

NEVER 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 ​

bash
NODE_ENV=production

This 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 install skips 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 ​

bash
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 TS

Set 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 ​

bash
# 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
bash
# 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 deploy user, not root
  • [ ] Node.js LTS installed (even-numbered major)
  • [ ] nvm alias default lts/* set so new shells get Node
  • [ ] .nvmrc committed and matching CI and local development
  • [ ] engines.node set in package.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 .bashrc prints output (would break scp/rsync)
  • [ ] Deployments use pnpm install --frozen-lockfile
  • [ ] NODE_ENV=production is set for the running app and verified
  • [ ] I know my Node version's EOL date and it is in my calendar
  • [ ] No --inspect flag anywhere in production configuration

Next: Level 7 — Git and Repository Deployment →