MHOSTMHOST

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 mHost VPS on Ubuntu 22.04/24.04, Debian 11/12, or AlmaLinux/Rocky Linux 8/9.
  • SSH access as root or a user with sudo rights.
  • Your Node.js app's code already uploaded to the server (for example, via git clone or scp) with a package.json in 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_name in 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:

bash
curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt-get install -y nodejs

AlmaLinux/Rocky Linux:

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

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

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

bash
source ~/.bashrc

Install and switch to the current LTS version:

bash
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.
  1. Install PM2 globally:
bash
sudo npm install -g pm2
If Node.js was installed via nvm, you don't need sudo: npm install -g pm2.
  1. Go to your app's directory and install its dependencies:
bash
cd /var/www/myapp
npm ci
If the project has no package-lock.json, use npm install instead.
  1. Start the app with PM2:
bash
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').
  1. Check its status and logs:
bash
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:
javascript
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.

  1. Generate the startup script:
bash
pm2 startup

PM2 detects the init system in use (systemd) and prints a ready-made command, roughly like this:

bash
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u root --hp /root

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

  1. Save the current process list:
bash
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

  1. Install Nginx and enable it on boot:

Ubuntu/Debian:

bash
sudo apt-get install -y nginx
sudo systemctl enable --now nginx

AlmaLinux/Rocky Linux:

bash
sudo dnf install -y nginx
sudo systemctl enable --now nginx
  1. Create a site config — the path depends on the distro.

Debian/Ubuntu: sites-available/sites-enabled

bash
sudo nano /etc/nginx/sites-available/myapp

AlmaLinux/Rocky Linux: conf.d

bash
sudo nano /etc/nginx/conf.d/myapp.conf

The file's contents are the same for both:

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

bash
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/

Test the configuration and reload it:

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

bash
sudo ufw allow 80,443/tcp

See the ufw firewall guide for details.

AlmaLinux/Rocky Linux (firewalld):

bash
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

  1. Make sure both Nginx and PM2 are enabled to start on boot:
bash
systemctl is-enabled nginx
systemctl is-enabled pm2-root

Both should print enabled (replace root with your actual user if you ran pm2 startup as someone else).

  1. Test it for real — reboot the server:
bash
sudo reboot

Wait a minute or two, reconnect over SSH, and check:

bash
pm2 status
curl -I http://127.0.0.1:3000
curl -I http://localhost/
systemctl status nginx

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