Node.js apps with PM2 and Nginx
A Node.js app started with a plain node app.js in an SSH session dies together with that session, and won't survive a crash or a server reboot. In this guide you'll install Node.js, run your app with the PM2 process manager (auto-restart on crash, auto-start after reboot), put Nginx in front of it as a reverse proxy, and confirm all of this survives a VPS reboot.
What you'll need
- An ForestsNet Cloud VPS on Ubuntu 22.04/24.04, Debian 11/12, or AlmaLinux/Rocky Linux 8/9.
- SSH access as
rootor a user withsudorights. - Your Node.js app's code already uploaded to the server (for example, via
git cloneorscp) with apackage.jsonin it. The examples below use the path/var/www/myapp— replace it with your own. - (optional) a domain whose A record points at the server's IP — needed for
server_namein Nginx. You can skip this and use the server's IP address instead.
ℹ️ Tip: the commands below are given with sudo. If you're already connected as root and sudo isn't installed, run the same commands without the sudo prefix.
Step 1. Install Node.js
There are two standard ways to install Node.js on a VPS: a system package from the NodeSource repository (recommended for production — integrates with systemd and PM2 out of the box), or nvm (handy if you need multiple Node versions or an install without root).
⚠️ Important: don't install Node.js with apt install nodejs / dnf install nodejs without adding a repository first — the version in a distro's default repositories is usually way out of date.
Option A: via NodeSource (system-wide Node.js)
The current LTS line at the time of writing is Node.js 24.x. Substitute a different version for 24 if you need one (for example, 22 for the previous LTS line).
Ubuntu/Debian:
curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt-get install -y nodejsAlmaLinux/Rocky Linux:
sudo dnf module disable -y nodejs
curl -fsSL https://rpm.nodesource.com/setup_24.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo dnf install -y nodejsℹ️ Tip: on AlmaLinux/Rocky, the built-in AppStream nodejs module is enabled by default — dnf module disable -y nodejs turns it off so the package doesn't conflict with the NodeSource version.
Verify the install:
node -v
npm -vℹ️ Tip: instead of a specific version number you can use setup_lts.x — it always points at the current Node.js LTS line.
Option B: via nvm (no root, multiple Node versions)
nvm installs Node.js into the user's home directory — no root needed, and you can keep several versions side by side and switch between them.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bashℹ️ Tip: at the time of writing, the current install-script tag is v0.40.5; check the nvm-sh/nvm releases page on GitHub for the latest one.
Re-open your SSH session, or run this for the current shell (bash):
source ~/.bashrcInstall and switch to the current LTS version:
nvm install --lts
nvm use --lts
node -v⚠️ Important: nvm installs Node.js only for the current user. If you plan to run PM2 as a systemd service (Step 3), run pm2 startup as this same user — the command will fill in the correct path to node on its own; copy it exactly as printed, don't retype it by hand.
Step 2. Install PM2 and run your app
ℹ️ Tip: in production, the app is often run under a dedicated unprivileged user rather than root — then sudo is only needed for system-level steps (Node.js, Nginx, the firewall), while pm2/npm run as that user.
- Install PM2 globally:
sudo npm install -g pm2ℹ️ If Node.js was installed via nvm, you don't need sudo: npm install -g pm2.
- Go to your app's directory and install its dependencies:
cd /var/www/myapp
npm ciℹ️ If the project has no package-lock.json, use npm install instead.
- Start the app with PM2:
pm2 start app.js --name myapp⚠️ Important: the app should listen only on 127.0.0.1 (localhost), not 0.0.0.0 — Nginx (Step 4) is what will expose it externally. For example, in Express: app.listen(3000, '127.0.0.1').
- Check its status and logs:
pm2 status
pm2 logs myappℹ️ Tip: for repeatable starts (especially with more than one app), it's more convenient to describe the process in ecosystem.config.js:
module.exports = {
apps: [
{
name: "myapp",
script: "./app.js",
cwd: "/var/www/myapp",
instances: 1,
exec_mode: "fork",
env: {
NODE_ENV: "production",
PORT: 3000
}
}
]
};Start it with: pm2 start ecosystem.config.js.
Step 3. Save the process list and enable PM2 startup
Until PM2's process list is saved and startup is configured, your app won't survive a server reboot.
- Generate the startup script:
pm2 startupPM2 detects the init system in use (systemd) and prints a ready-made command, roughly like this:
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u root --hp /rootCopy and run exactly the command PM2 printed for you — the PATH, the user (-u), and the home directory (--hp) will be filled in for your system.
- Save the current process list:
pm2 save✅ Done: now, after a server reboot, systemd will bring up PM2, and PM2 will bring up all the saved processes.
ℹ️ Tip: re-run pm2 save after any change to the process list (added or removed an app). After upgrading your Node.js version, run pm2 unstartup, then pm2 startup again — otherwise the systemd service will keep pointing at the old node binary.
Step 4. Set up Nginx as a reverse proxy
- Install Nginx and enable it on boot:
Ubuntu/Debian:
sudo apt-get install -y nginx
sudo systemctl enable --now nginxAlmaLinux/Rocky Linux:
sudo dnf install -y nginx
sudo systemctl enable --now nginx- Create a site config — the path depends on the distro.
Debian/Ubuntu: sites-available/sites-enabled
sudo nano /etc/nginx/sites-available/myappAlmaLinux/Rocky Linux: conf.d
sudo nano /etc/nginx/conf.d/myapp.confThe file's contents are the same for both:
server {
listen 80;
server_name example.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;
}
}Replace example.com with your own domain or the server's IP, and 127.0.0.1:3000 with the address and port your app listens on.
On Debian/Ubuntu, also enable the site via a symlink:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/Test the configuration and reload it:
sudo nginx -t
sudo systemctl reload nginx⚠️ Important: on AlmaLinux/Rocky Linux with SELinux enabled (enforcing by default), Nginx can't proxy requests even to a local TCP port by default — you'll get a 502 Bad Gateway. Allow it once:
sudo setsebool -P httpd_can_network_connect onℹ️ Tip: the proxy_http_version 1.1, Upgrade, and Connection "upgrade" lines in the config above are needed if your app uses WebSockets (for example, Socket.IO, or HMR in Next.js/Vite). If that's not your case, you can remove them.
Step 5. Open the ports in the firewall
Port 80 (and 443, if you add HTTPS later) needs to be open externally. There's no need to open the app's own port (3000 in the example) in the external firewall — only Nginx, on the same server, talks to it.
Ubuntu/Debian (ufw):
sudo ufw allow 80,443/tcpSee the ufw firewall guide for details.
AlmaLinux/Rocky Linux (firewalld):
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload⚠️ Important: don't open the app's port (3000 in the example) in the external firewall — all external traffic should go through Nginx on 80/443 only.
Step 6. Confirm everything survives a reboot
- Make sure both Nginx and PM2 are enabled to start on boot:
systemctl is-enabled nginx
systemctl is-enabled pm2-rootBoth should print enabled (replace root with your actual user if you ran pm2 startup as someone else).
- Test it for real — reboot the server:
sudo rebootWait a minute or two, reconnect over SSH, and check:
pm2 status
curl -I http://127.0.0.1:3000
curl -I http://localhost/
systemctl status nginxIf pm2 status shows your app as online and both curl commands return an HTTP response, everything is set up correctly.
✅ Done: your app is reachable through Nginx at your domain or IP, and it will survive any server reboot.
What's next
The usual next step is adding HTTPS (for example, via Let's Encrypt/certbot) and opening the rule for 443 in the firewall (see Step 5). If you run several apps on the same server, give each one its own Nginx config and its own entry in ecosystem.config.js, and remember to run pm2 save after any change to the process list.