How to Install Node.js on Ubuntu 24.04 VPS — LTS, NVM, and Production Setup
Node.js powers a staggering share of modern web infrastructure -- Next.js frontends, Express APIs, real-time websocket servers, build toolchains, and countless CLI utilities. Getting it installed correctly on a fresh Ubuntu 24.04 VPS is the difference between a deployment that runs for years with zero drama and one that blocks your first npm install with permission errors. This guide walks you through every install method, the right process manager, reverse-proxy integration, and the production hardening that keeps long-running Node services stable.
Skip the manual setup? Our one-click Node.js app image ships with Node 20 LTS, pm2, and Nginx pre-configured. Launch a CloudCore Starter VPS and you'll be deploying in under 60 seconds.
Table of Contents
Why Node.js 20 LTS?
Node.js releases on a six-month cadence, but only even-numbered releases get promoted to Long Term Support (LTS) status. LTS lines receive critical bug fixes and security patches for roughly 30 months, which is exactly what you want on a production VPS. As of this guide, Node.js 20 (codename "Iron") is the active LTS release and is supported through April 2026, with Node.js 22 ("Jod") entering LTS shortly after. Node 18 is in maintenance mode and Node 21/23 are odd-numbered, short-lived current releases -- fine for local experimentation, a poor choice for a server you expect to leave running.
Node 20 is the right default for almost every server workload today. It ships a stable node:test runner, native fetch, WebCrypto, the permissions model, and full support for every major framework (Next.js 14+/15, NestJS 10+, Fastify 4+, Remix, Nuxt, Astro, SvelteKit). The NodeSource repository we use below makes sticking to LTS trivial -- you subscribe to the setup_20.x channel and get patch updates through the normal apt upgrade workflow, with no extra tooling required.
There are three install methods worth knowing, each suited to a different job:
- NodeSource APT repo -- the recommended path for production servers. Integrates with
apt, handles security updates, installs a single global Node version. - nvm (Node Version Manager) -- ideal for developer machines and CI runners where you need to juggle multiple Node versions per project.
- Official tarball -- for air-gapped or policy-restricted environments where you cannot add third-party repositories.
PATH confusion.Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to your server (PuTTY on Windows, or the built-in terminal on macOS/Linux)
- At least 1 GB of RAM (2 GB+ recommended if you plan to run
npm installon larger projects or compile native modules) - At least 10 GB of free disk space
- A non-root user with sudo privileges (recommended -- we will use
deployin examples)
Recommended Plan: CloudCore Starter>
For a typical Node.js web app (Next.js, Express, Fastify, NestJS), we recommend the CloudCore Starter plan:>
- 2 vCPU cores
- 4 GB RAM
- 50 GB NVMe SSD
- Unmetered bandwidth
- Node.js 20 LTS one-click image available>
This gives you enough headroom for pm2 cluster mode across both cores, plus a reverse proxy and a small database on the same box. Scale up when your app or your node_modules start to feel cramped.Connect to your server via SSH to get started:
ssh deploy@your-server-ipStep 1: Update System Packages
Start by refreshing the package index and applying outstanding upgrades. This gives you current security patches and makes sure apt can resolve Node's dependencies cleanly.
sudo apt update && sudo apt upgrade -yInstall a handful of utilities you will need for every install method below (the build-essential package covers gcc, g++, and make, which native Node modules like bcrypt, sharp, and better-sqlite3 require):
sudo apt install -y curl ca-certificates gnupg build-essentialIf the kernel was updated, reboot before continuing:
sudo rebootThen reconnect via SSH after a minute.
Step 2: Install Node.js via the NodeSource APT Repository (Recommended)
NodeSource maintains official Debian and Ubuntu repositories for every supported Node.js release line. This is the path we recommend for production servers -- it installs a real apt package, keeps you on a specific LTS line, and delivers security patches through the normal apt upgrade workflow.
Run the official setup script to add the Node 20 repository and signing key:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -Expected output (abbreviated):
2025-xx-xx - Installing pre-requisites
2025-xx-xx - Repository configured successfully.
2025-xx-xx - To install Node.js, run: apt-get install nodejs -yThen install Node.js itself:
sudo apt install -y nodejsThis single package includes node, npm, and npx. Verify the install:
node --version
npm --versionExpected output:
v20.18.1
10.8.2From this point, any future sudo apt update && sudo apt upgrade will pick up Node 20.x patch releases automatically. When Node 22 LTS becomes your preferred line, you re-run the setup script with setup_22.x and apt upgrade handles the rest.
If you prefer a different release line, substitute setup_18.x, setup_22.x, etc. Check the current list of supported channels at github.com/nodesource/distributions.
Step 3: Alternative — Install Node.js via NVM (Dev Boxes)
nvm (Node Version Manager) lets you install and switch between multiple Node.js versions per user, without touching system packages. It is the right tool for developer workstations, CI runners, and any server where different projects pin to different Node versions. On a single-app production VPS, NodeSource is still the better choice -- nvm is a shell function, so systemd services need extra plumbing to find the right node binary.
Install the latest nvm release (check github.com/nvm-sh/nvm for the current version string):
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bashReload your shell so the nvm function is in scope:
source ~/.bashrcInstall Node 20 LTS and set it as the default:
nvm install 20
nvm alias default 20
nvm use 20Expected output:
Now using node v20.18.1 (npm v10.8.2)
default -> 20 (-> v20.18.1)Verify the install:
node --version
which nodeThe which node path will point inside ~/.nvm/versions/node/v20.18.1/bin/node -- remember this if you write systemd units later.
Useful nvm commands:
nvm install 22-- install another version alongside 20nvm ls-- list every version installed locallynvm use 18-- switch the current shell to Node 18nvm current-- print the active versionnvm uninstall 18-- remove a version you no longer need
.nvmrc file in your project root (containing just 20 or lts/iron) lets collaborators run nvm use and automatically pick the right version.Step 4: Alternative — Install Node.js from the Official Tarball (Locked-Down Environments)
Some environments forbid adding third-party APT repositories, scripts piped from the internet, or user-level version managers. For those cases, Node.js publishes plain tarballs at nodejs.org that you can drop into /opt or /usr/local manually.
Download the Linux x64 LTS tarball (swap in the current LTS version number from nodejs.org):
cd /tmp
curl -fsSLO https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.xzExtract it into /opt:
sudo tar -xJf node-v20.18.1-linux-x64.tar.xz -C /opt
sudo ln -sfn /opt/node-v20.18.1-linux-x64 /opt/nodeAdd /opt/node/bin to the system PATH for every user:
echo 'export PATH=/opt/node/bin:$PATH' | sudo tee /etc/profile.d/nodejs.sh
sudo chmod +x /etc/profile.d/nodejs.shLog out and back in (or source /etc/profile.d/nodejs.sh) and verify:
node --version
npm --versionTo upgrade later, you download the new tarball, extract it under /opt, and re-point the /opt/node symlink. Because the binaries live in /opt, there is nothing to clash with a future APT install if your policy changes.
Step 5: Choose a Package Manager (npm, pnpm, yarn, bun)
npm ships with Node and is the safe default. Three alternatives are worth knowing because each solves a specific real-world pain point:
- pnpm -- stores every package once in a global content-addressable store and hard-links it into each project's
node_modules. This cuts disk usage dramatically on a VPS that hosts multiple Node apps, andpnpm installis typically 2-3x faster thannpm installon cold caches. Install withnpm install -g pnpmorcorepack enable pnpm. - yarn -- Meta's original alternative to npm. Yarn v1 (Classic) is still widely used for legacy projects; Yarn Berry (v3/v4) adds Plug'n'Play (no
node_modules). Install withcorepack enable yarn. - bun -- an all-in-one JavaScript runtime, bundler, test runner, and package manager written in Zig. As a drop-in package manager (
bun install) it is usually the fastest option and works with your existingpackage.json. Install withcurl -fsSL https://bun.sh/install | bash.
sudo corepack enableAfter that, running pnpm or yarn inside a project with a packageManager field in its package.json will automatically fetch the exact pinned version. This keeps builds reproducible across your laptop, CI, and the VPS.
For a single production app, pick one package manager and stick with it -- mixing npm install and pnpm install in the same project will desync your lockfile and produce mysterious bugs.
Step 6: Install pm2 and Run Your First App
A Node process needs a supervisor. If you just run node server.js over SSH, the process dies the moment you disconnect. pm2 is the de facto standard -- it keeps your app running, restarts it on crashes, streams logs, and can fan it out across multiple CPU cores with cluster mode.
Install pm2 globally:
sudo npm install -g pm2Create a minimal test app:
mkdir -p ~/my-app && cd ~/my-appcat > server.js <<'EOF'
const http = require('http');
const port = process.env.PORT || 3000;
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(Hello from Node ${process.version} on pm2!\n);
}).listen(port, () => console.log(Listening on :${port}));
EOFStart it under pm2:
pm2 start server.js --name my-appExpected output:
[PM2] Starting /home/deploy/my-app/server.js in fork_mode (1 instance)
[PM2] Done.
┌────┬──────────┬─────────┬─────────┬────────┐
│ id │ name │ mode │ status │ uptime │
├────┼──────────┼─────────┼─────────┼────────┤
│ 0 │ my-app │ fork │ online │ 0s │
└────┴──────────┴─────────┴─────────┴────────┘Curl it locally:
curl http://localhost:3000Use an ecosystem.config.js File
For anything beyond a demo, describe your app declaratively. Create ~/my-app/ecosystem.config.js:
module.exports = {
apps: [
{
name: 'my-app',
script: './server.js',
instances: 'max', // 'max' = one process per CPU core (cluster mode)
exec_mode: 'cluster',
watch: false,
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
error_file: './logs/err.log',
out_file: './logs/out.log',
merge_logs: true,
time: true,
},
],
};Start the app from the config:
pm2 start ecosystem.config.jsUseful pm2 commands:
pm2 list-- show every managed processpm2 logs my-app-- tail stdout and stderrpm2 restart my-app-- restart after a deploypm2 reload my-app-- zero-downtime restart (cluster mode only)pm2 stop my-app-- stop without removing from pm2pm2 delete my-app-- remove from pm2 entirelypm2 monit-- live CPU/memory dashboard
Step 7: Persist pm2 Across Reboots with systemd
Out of the box, pm2 does not survive a reboot. Two commands fix that. The first generates a systemd unit that launches pm2 on boot, the second snapshots your currently running apps so pm2 restarts them.
Generate the systemd unit (pm2 prints a command -- run exactly what it outputs):
pm2 startup systemdExpected output (copy the last line and run it):
[PM2] Init System found: systemd
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u deploy --hp /home/deployThen save the current process list so pm2 resurrects it on boot:
pm2 saveTest it end-to-end by rebooting:
sudo rebootReconnect via SSH, run pm2 list, and your app should be back online.
To undo this later:
pm2 unstartup systemdStep 8: Alternative — Native systemd Unit
If you would rather skip pm2 entirely (single-instance apps that do not need cluster mode, or environments that frown on extra daemons), a native systemd unit is the minimal approach. systemd handles restart-on-crash, log capture via journalctl, and boot persistence with no Node.js-specific tooling.
Create a dedicated service file:
sudo tee /etc/systemd/system/my-app.service > /dev/null <<'EOF' [Unit] Description=my-app Node.js service After=network.target[Service] Type=simple User=deploy Group=deploy WorkingDirectory=/home/deploy/my-app Environment=NODE_ENV=production Environment=PORT=3000 ExecStart=/usr/bin/node /home/deploy/my-app/server.js Restart=always RestartSec=5 StandardOutput=journal StandardError=journal SyslogIdentifier=my-app
Hardening
NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=read-only ReadWritePaths=/home/deploy/my-app/logs
[Install] WantedBy=multi-user.target EOF
Reload systemd and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now my-app
sudo systemctl status my-appTail logs with journalctl:
sudo journalctl -u my-app -fIf you installed Node via nvm, replace /usr/bin/node with the absolute path from which node (e.g. /home/deploy/.nvm/versions/node/v20.18.1/bin/node).
Step 9: Put Node.js Behind Nginx
Exposing Node directly on port 80 or 443 is a bad habit -- Node's built-in TLS is fine, but a reverse proxy gives you HTTP/2, gzip/brotli, static asset caching, access logs, rate limiting, and painless multi-app hosting on a single IP. Nginx is the standard choice.
See our complete walkthrough: How to Install Nginx on Ubuntu 24.04. Once Nginx is installed, drop a config like this at /etc/nginx/sites-available/my-app:
server { listen 80; server_name app.yourdomain.com;
location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; proxy_read_timeout 60s; } }
Enable the site, test the config, obtain a Let's Encrypt certificate, and reload:
sudo ln -s /etc/nginx/sites-available/my-app /etc/nginx/sites-enabled/
sudo nginx -t
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.yourdomain.com
sudo systemctl reload nginxNginx now terminates TLS, handles HTTP/2, and forwards plain HTTP to Node on 127.0.0.1:3000. Bind Node to 127.0.0.1 (not 0.0.0.0) so the outside world can only reach it via Nginx.
Step 10: Rotate Logs with pm2-logrotate
Left alone, pm2's out.log and err.log files grow without bound. Eventually they fill the disk and your app stops writing. The pm2-logrotate module solves this.
Install and configure it:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M pm2 set pm2-logrotate:retain 14 pm2 set pm2-logrotate:compress true pm2 set pm2-logrotate:rotateInterval '0 0 *'
This rotates logs nightly (or sooner if any file exceeds 10 MB), keeps 14 days of gzipped history, and requires no cron jobs of your own. Inspect current settings with pm2 conf.
If you chose the native systemd path instead of pm2, journalctl already handles rotation through /etc/systemd/journald.conf -- check SystemMaxUse= and MaxRetentionSec=.
Security Hardening
A few habits separate a hobby deploy from a production-grade one:
- Never run Node as root. Create a dedicated system user (e.g.
deployornodeapp) and run pm2/systemd under that account. Root-owned Node processes that are compromised give attackers the whole box. - Do not use
sudo npm install. Global installs as root breaknode_modulespermissions and expose your system to maliciouspostinstallscripts. Install globals either as the deploy user (npm install -gwith~/.npm-globalprefix) or with a version manager like nvm. - Bind to
127.0.0.1, not0.0.0.0. Node should only accept connections from Nginx on the same host. Combine withufwto keep ports 3000/4000/5000 firewalled. - Drop privileges for ports below 1024. Never give Node
CAP_NET_BIND_SERVICEor run it as root just so it can listen on 80/443 -- put Nginx or Caddy in front and let Node keep its high port. - Pin dependencies with a lockfile (
package-lock.json,pnpm-lock.yaml, oryarn.lock) and commit it. Runnpm ci(notnpm install) in production so exact versions are installed. - Run
npm auditin CI. Fail the build on high/critical vulnerabilities. For deeper scanning, Snyk and Socket catch malicious packages that plain audit misses. - Set
NODE_ENV=production. Many frameworks (Express, React SSR, Next.js) skip dev-only checks and enable caching only when this is set. - Use a systemd
PrivateTmp,ProtectSystem=strict, andNoNewPrivilegessandbox (shown in Step 8) so a compromised Node process cannot trivially escalate.
Upgrading Node.js
NodeSource path: Either apt upgrade keeps you on the patch releases of your current major line, or you re-run the setup script with a new line (e.g. setup_22.x) and apt install -y nodejs to hop majors. Restart pm2 (pm2 restart all --update-env) or systemd (sudo systemctl restart my-app) after an upgrade so the service picks up the new binary.
nvm path: nvm install 22 && nvm alias default 22. Re-run nvm reinstall-packages 20 if you want to copy globally installed packages to the new version. Update any systemd unit that hardcoded the old version path.
Tarball path: Download the new tarball to /opt, re-point the /opt/node symlink, restart your services.
Before any major-version bump (20 -> 22, for example), check the Node.js changelog and test in staging. Breaking changes between LTS lines are rare but not zero.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
npm ERR! EACCES: permission denied on global install | Writing to /usr/lib/node_modules without sudo, or previously ran sudo npm install and corrupted ownership | Never use sudo npm install -g. Set a user-owned prefix: mkdir ~/.npm-global && npm config set prefix '~/.npm-global' and add ~/.npm-global/bin to PATH. Or use nvm. |
gyp ERR! stack Error: not found: make during install | Native module needs a compiler; build-essential is missing | sudo apt install -y build-essential python3 and re-run npm install. |
node-gyp rebuild fails with Python errors | Ubuntu 24.04 ships only Python 3; older node-gyp expected python | Install python3 and export npm_config_python=/usr/bin/python3, or upgrade node-gyp: npm install -g node-gyp@latest. |
Error: listen EACCES: permission denied 0.0.0.0:80 | Non-root users cannot bind to ports below 1024 | Put Nginx in front and have Node listen on 3000/4000/etc. Do not grant CAP_NET_BIND_SERVICE to node in production. |
Error: listen EADDRINUSE: address already in use :::3000 | Another process is already on port 3000 (often an old Node instance that pm2 lost track of) | Find it: sudo lsof -i :3000 and kill the PID. Then pm2 resurrect if pm2 has lost the app. |
node: command not found after install | Shell hasn't reloaded, or nvm is not sourced in non-interactive shells | Log out and back in. For nvm under systemd or cron, source ~/.nvm/nvm.sh inside the script or use an absolute path to the node binary. |
| pm2 apps do not come back after reboot | pm2 save was never run, or startup script was not installed | Run pm2 startup systemd, execute the printed command, start your apps, then pm2 save. |
JavaScript heap out of memory | Node defaults to a ~1.7 GB heap | Start with NODE_OPTIONS=--max-old-space-size=4096 node server.js, or set max_memory_restart in your pm2 ecosystem file. |
npm install is painfully slow | Default registry latency, or many native builds | Switch to pnpm (content-addressable store, far faster) or set npm config set fund false && npm config set audit false in CI. |
Viewing Logs
With pm2:
pm2 logs my-app --lines 200With systemd:
sudo journalctl -u my-app -fFAQ
Should I use the NodeSource repo, nvm, or the tarball?
Use the NodeSource APT repo on production servers -- it gives you an apt-managed package, automatic security patches, and a single global Node version that systemd, pm2, and every user on the box agree on. Use nvm on developer machines and CI runners where you need to switch between Node 18, 20, and 22 per project. Use the official tarball only when policy prevents adding third-party repositories or piping scripts from the internet. Mixing methods on one server is a common source of "which node am I actually running?" debugging sessions.
Do I need pm2 if I already use Docker or systemd?
No. pm2 solves the same problems (restart on crash, boot persistence, log capture) that Docker and systemd already solve. The reasons to still reach for pm2 are its cluster mode (one process per CPU core with a built-in load balancer) and its developer ergonomics (pm2 logs, pm2 monit, pm2 reload for zero-downtime deploys). If your Node app is single-process and you prefer the Unix-native stack, a plain systemd unit (Step 8) is a fine choice. If you run containers, Node behind restart: unless-stopped in Docker Compose plus a reverse proxy is enough.
How do I listen on port 443 from Node?
Don't. Put Nginx, Caddy, or Traefik in front of Node and terminate TLS there. The reverse proxy handles Let's Encrypt renewals, HTTP/2, HTTP/3, gzip/brotli, and request logging -- all things that live outside your application code. Node binds to a high port like 3000 and only accepts connections from 127.0.0.1. See our Nginx install guide and our systemd service primer for the full setup.
What is the difference between pm2 fork mode and cluster mode?
Fork mode runs exactly one Node process per app. It is the default and works for any code. Cluster mode (exec_mode: 'cluster' with instances: 'max') uses Node's built-in cluster module to fork one worker per CPU core and load-balance requests across them. Cluster mode scales a stateless HTTP server to use all your vCPUs and enables zero-downtime pm2 reload. It does not work for apps that assume a single process (in-memory rate limiters, local websocket rooms without a shared backend, cron jobs that must run exactly once) -- move that shared state to Redis or dedicate a separate fork-mode worker for it.
Can I run multiple Node apps on the same VPS?
Yes, this is the normal case. Each app listens on its own high port (3000, 3001, 4000, etc.), pm2 or systemd supervises each one, and Nginx routes incoming hostnames to the right backend. A CloudCore Starter (2 vCPU / 4 GB) comfortably runs two or three small Node APIs alongside a reverse proxy. Watch your RAM -- pm2 monit and htop tell you quickly when it is time to scale up.
Next Steps
Now that Node.js is running on your VPS, here are recommended next steps to build on your setup:
- Put Nginx in front of it -- Follow our Nginx install guide and the reverse-proxy config in Step 9 to add TLS, HTTP/2, and per-app virtual hosts.
- Master systemd for long-running services -- Read our systemd service basics to understand the sandboxing directives (
ProtectSystem,PrivateTmp,NoNewPrivileges) that harden every service on your box, not just Node.
- Add a database -- Install PostgreSQL, MongoDB, or Redis on the same VPS and connect over
127.0.0.1for low-latency, zero-egress data access.
- Automate deploys -- Drop a GitHub Actions workflow that SSHes in, runs
git pull && npm ci --omit=dev && pm2 reload ecosystem.config.js, and you have zero-downtime deploys in under 50 lines of YAML.
- Wire up monitoring -- Install Uptime Kuma for endpoint health checks and ship pm2 logs to Grafana Loki for searchable long-term storage.
- Explore the ecosystem -- The official docs at nodejs.org, the NodeSource release channel list at github.com/nodesource/distributions, the nvm project at github.com/nvm-sh/nvm, and the pm2 handbook at pm2.keymetrics.io are the four references worth bookmarking.
Skip the Manual Install — Get Node.js Pre-Installed>
Our CloudCore Starter VPS ships with a one-click Node.js app image that includes everything in this guide:>
- Node.js 20 LTS installed via the NodeSource APT repository
- pm2 with cluster mode and log rotation pre-configured
- Nginx reverse proxy with HTTP/2 and Let's Encrypt-ready config
- Dedicated non-root deploy user with SSH key access
- systemd-managed pm2 daemon that survives reboots
- ufw firewall locked down to ports 22, 80, and 443
>
Launch Your Node.js VPS Now -- CloudCore Starter plans from EUR 7.99/month.