Deploy a Node.js app with PM2 and nginx on Ubuntu 24.04

Run a Node.js app under its own user with PM2, bring it back after reboots, put nginx in front as a reverse proxy, and ship updates without downtime.

Tested on Ubuntu 24.04 LTS

Before you start

  • A server running Ubuntu 24.04
  • Root or a user with sudo
  • A domain name pointed at the server (for HTTPS)
  • Your app's code in a Git repository

To deploy a Node.js app on Ubuntu 24.04, run it as an unprivileged user under PM2 (which restarts it when it crashes and starts it at boot), have it listen on 127.0.0.1 only, and put nginx in front on ports 80 and 443. This tutorial does each step with an Express app as the example, then shows how to ship an update with a zero-downtime reload.

The same steps work for any Node.js server: Express, Fastify, NestJS, or the standalone output of Next.js and Nuxt. Only the start file changes.

What In this tutorial
The app's user deploy
The app's directory /home/deploy/myapp
The port the app listens on 3000, on 127.0.0.1 only
The domain app.example.com

Install Node.js

Install the current LTS release of Node.js from NodeSource's repository:

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

Check it worked:

bash
node -v
npm -v

On 21 October 2026 this installed Node.js v24.22.1 and npm 11.19.1. Ubuntu's own nodejs package is Node.js 18, which the Node.js project no longer supports, so don't use it for a new app. How to install Node.js on Ubuntu 24.04 compares NodeSource, nvm and Ubuntu's package if you need a specific version.

Install PM2

PM2 is a process manager for Node.js: it keeps the app running, restarts it if it crashes, collects its logs and starts it again after a reboot. Install it globally:

bash
sudo npm install -g pm2
pm2 -v

This tutorial was tested with PM2 7.1.0.

Create a user for the app

Don't run the app as root. If someone finds a hole in it, they get only what this user can touch:

bash
sudo adduser --disabled-password --gecos "" deploy
sudo -iu deploy

The rest of the app steps run as deploy. You'll switch back to your sudo user for nginx.

Put the code on the server

As deploy, clone your repository and install its production dependencies:

bash
git clone https://github.com/your-name/myapp.git ~/myapp
cd ~/myapp
npm ci --omit=dev

npm ci installs exactly what package-lock.json lists, which is what you want on a server. If your app has a build step (TypeScript, Next.js), run npm ci without --omit=dev, then npm run build.

If you don't have an app yet and want to follow along, make a minimal Express one:

bash
mkdir -p ~/myapp && cd ~/myapp
npm init -y
npm install express
js
// ~/myapp/server.js
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;

app.get("/", (req, res) => res.send("Hello from Node.js " + process.version + "\n"));

app.listen(port, "127.0.0.1", () => console.log("listening on 127.0.0.1:" + port));

Two details matter on a server. The app reads its port from PORT, so PM2 can set it. And it listens on 127.0.0.1, not on every address: only nginx on the same server can reach it, so nobody bypasses the proxy on port 3000. In your own app, find the listen() call and give it "127.0.0.1" as the host.

Describe the app in an ecosystem file

PM2 can start an app from the command line, but an ecosystem file keeps the settings in your repository. Create ~/myapp/ecosystem.config.cjs:

js
module.exports = {
  apps: [
    {
      name: "myapp",
      script: "server.js",
      cwd: "/home/deploy/myapp",
      instances: 1,
      env: {
        NODE_ENV: "production",
        PORT: 3000,
      },
      max_memory_restart: "300M",
    },
  ],
};
  • instances: 1 runs the app in PM2's cluster mode with one process, which is what lets pm2 reload swap it without dropping requests. Set "max" for one process per CPU core, but only if your app keeps no state in memory (sessions, caches) that the processes would need to share.
  • max_memory_restart restarts the app if it grows past 300 MB, a safety net for slow memory leaks. Size it to your plan's memory.
  • The .cjs extension makes it load whether or not your package.json says "type": "module".

Keep secrets such as database passwords out of this file if it's in Git. Put them in a .env file the app reads, readable by deploy only (chmod 600 .env).

Start the app with PM2

bash
cd ~/myapp
pm2 start ecosystem.config.cjs
pm2 ls

Check it worked: pm2 ls shows myapp with the status online, and the app answers on its local port:

bash
curl http://127.0.0.1:3000
text
Hello from Node.js v24.22.1

If the status is errored or the restart counter (↺) keeps climbing, read the app's output with pm2 logs myapp --lines 50.

Start it again after a reboot

Save the list of running apps, then ask PM2 for its startup command:

bash
pm2 save
pm2 startup systemd -u deploy --hp /home/deploy

PM2 prints a command that starts with sudo env PATH=$PATH:/usr/bin pm2 startup systemd. Leave the deploy shell (exit) and run that exact command as your sudo user. It writes a systemd service called pm2-deploy that runs pm2 resurrect at boot, which restarts everything you saved.

Check it worked:

bash
systemctl status pm2-deploy

It should be enabled. Run pm2 save again whenever you add or remove an app, or the next boot brings back the old list.

Put nginx in front

As your sudo user, install nginx:

bash
sudo apt install -y nginx

Create /etc/nginx/sites-available/myapp:

nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

upstream myapp {
    server 127.0.0.1:3000;
    keepalive 16;
}

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location / {
        proxy_pass http://myapp;
        proxy_http_version 1.1;
        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_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}
  • The map and the Upgrade/Connection headers let WebSockets (Socket.IO, live reload, GraphQL subscriptions) through. For ordinary requests the Connection header is left empty, so nginx can keep connections to the app open (keepalive 16) instead of opening one per request.
  • The X-Forwarded-* headers tell the app the visitor's real address and that the request came in over HTTPS. In Express, add app.set("trust proxy", "loopback") so req.ip and req.secure use them.
  • map is allowed once per variable name across all sites. If you proxy a second app, leave the map block out of its file.

Enable the site, drop the default one and reload:

bash
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Check it worked: nginx -t says syntax is ok and test is successful, and the app answers through nginx:

bash
curl -H "Host: app.example.com" http://127.0.0.1/

Open the firewall and add HTTPS

Allow web traffic in the server's firewall if ufw is active:

bash
sudo ufw allow "Nginx Full"

On VPSNine, a server with a panel firewall also needs rules for TCP 80 and 443. Don't open port 3000: the app is only for nginx.

Then get a free certificate with Certbot's nginx plugin. Serve a site with nginx and HTTPS has the DNS records, the certbot --nginx command and how renewals work; it edits the server block above for you and adds the redirect from HTTP.

Check it worked: https://app.example.com loads your app with a padlock.

Ship an update without downtime

As deploy, pull the new code, install dependencies and reload:

bash
cd ~/myapp
git pull
npm ci --omit=dev
pm2 reload myapp

pm2 reload starts a new process, waits for it to come up and only then stops the old one, so requests keep being answered. pm2 restart stops first and starts after, which drops requests for a moment. If you changed the ecosystem file itself, run pm2 reload ecosystem.config.cjs --update-env so PM2 picks up the new settings, then pm2 save.

Check it worked: pm2 ls shows the app online with a fresh uptime, and curl http://127.0.0.1:3000 returns the new version.

Keep the logs in check

PM2 writes the app's output to ~/.pm2/logs/ and never trims it. Its log rotation module caps the files:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7

Read the latest lines at any time with pm2 logs myapp --lines 100, or watch CPU and memory per process with pm2 monit.

Troubleshooting

502 Bad Gateway from nginx. nginx can't reach the app. /var/log/nginx/error.log shows:

text
connect() failed (111: Connection refused) while connecting to upstream, ... upstream: "http://127.0.0.1:3000/"

The app isn't running or listens on another port. Check pm2 ls and pm2 logs myapp, and that PORT in the ecosystem file matches the upstream block.

The app doesn't come back after a reboot, and pm2 ls is empty. The boot service ran but nothing was saved, or it runs as another user. Start the app as deploy, run pm2 save as deploy, and check the service is pm2-deploy (not pm2-root).

Error: listen EADDRINUSE: address already in use 127.0.0.1:3000. Another process holds the port, often a copy you started by hand with node server.js. Find it with sudo ss -ltnp 'sport = :3000' and stop it.

WebSockets fail to connect through nginx (the browser console shows the handshake failing), while normal pages work. The Upgrade and Connection headers aren't being passed. Check the two proxy_set_header lines and the map block are in the config, then sudo nginx -t && sudo systemctl reload nginx.

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee