PM2 is a process manager for Node.js that keeps applications running, restarts them when they crash, spreads them across CPU cores in cluster mode and collects their logs. In this tutorial you will deploy a small Express application on Ubuntu 24.04 with PM2, describe it in an ecosystem file, make PM2 start it automatically at boot through systemd, rotate its logs, and publish it through Nginx. You will also run a zero-downtime reload, which is how you roll out new versions.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS.
  • A non-root user with sudo privileges. This guide uses your_user in paths; replace it with your username.
  • Node.js and npm installed system-wide, for example Node.js 24 LTS from the NodeSource repository. Check with node --version.
  • Optionally, a domain name with an A record pointing to your_server_ip if you want to serve the app by name.

Step 1 - Installing PM2

Install PM2 globally so the pm2 command is available to every user:

sudo npm install -g pm2

Verify the installation:

pm2 --version
6.0.13

The first time you run a pm2 command as your user, PM2 starts its daemon and creates ~/.pm2, where it keeps logs, PID files and the saved process list. Run PM2 as your normal user, not with sudo, so the daemon and the apps it launches do not run as root.

Step 2 - Creating a sample application

If you already have an application, you can skip to Step 3 and use its entry point instead. Otherwise, create a minimal Express API to follow along:

mkdir -p ~/apps/my-api
cd ~/apps/my-api
npm init -y
npm install express

Create the entry point:

nano app.js
const express = require('express');

const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({ status: 'ok', env: process.env.NODE_ENV, pid: process.pid });
});

const server = app.listen(port, '127.0.0.1', () => {
  console.log(`my-api listening on 127.0.0.1:${port}`);
});

// Let PM2 stop or reload the process cleanly
process.on('SIGINT', () => {
  server.close(() => process.exit(0));
});

The SIGINT handler matters: when PM2 reloads or stops a process it sends SIGINT first, and closing the server lets in-flight requests finish before the process exits.

Step 3 - Writing an ecosystem file

You can start an app with pm2 start app.js, but an ecosystem file keeps every option in version control and makes restarts reproducible. Create it in the project root:

nano ~/apps/my-api/ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'my-api',
      script: './app.js',
      cwd: '/home/your_user/apps/my-api',
      instances: 'max',
      exec_mode: 'cluster',
      max_memory_restart: '300M',
      time: true,
      env: {
        NODE_ENV: 'development',
        PORT: 3000,
      },
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
    },
  ],
};

What these options do:

  • instances: 'max' with exec_mode: 'cluster' starts one worker per CPU core. All workers share port 3000; PM2 balances connections between them, so you do not need one port per instance.
  • max_memory_restart restarts a worker that grows beyond 300 MB, a safety net against memory leaks.
  • time: true prefixes every log line with a timestamp.
  • env_production is applied when you pass --env production.

Start the app with the production environment:

cd ~/apps/my-api
pm2 start ecosystem.config.js --env production

List the processes:

pm2 ls

PM2 prints a table with one row per worker. On a 2 vCPU server you will see two rows named my-api, both with mode cluster and status online. Send a few requests and notice that the pid changes as PM2 distributes them:

curl http://127.0.0.1:3000
{"status":"ok","env":"production","pid":14231}

Step 4 - Starting PM2 at boot

PM2 can generate a systemd unit that starts the daemon at boot and restores the processes you saved. Run:

pm2 startup systemd

Because you are not root, PM2 does not change the system itself. It prints the exact command to run:

[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 your_user --hp /home/your_user

Copy and run the command that PM2 printed on your server. It creates and enables a unit named pm2-your_user.service.

Now save the current process list. This is what PM2 restores at boot:

pm2 save

Check the systemd unit:

systemctl status pm2-your_user
● pm2-your_user.service - PM2 process manager
     Loaded: loaded (/etc/systemd/system/pm2-your_user.service; enabled; preset: enabled)
     Active: active (running) since ...

To confirm the full cycle, reboot with sudo reboot, reconnect and run pm2 ls. Both workers should be online again. Remember to run pm2 save again every time you add or remove apps.

Step 5 - Managing logs

PM2 writes each app's standard output and error streams to ~/.pm2/logs/. Follow them live:

pm2 logs my-api --lines 20

Press CTRL+C to stop following. These files grow forever by default, so install the pm2-logrotate module:

pm2 install pm2-logrotate

Configure it to rotate at 10 MB, keep 14 files and compress old ones:

pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true

Check the module settings:

pm2 conf pm2-logrotate

The module runs as its own PM2 process, so pm2 ls now shows it under a separate Module section. Run pm2 save again so it is restored after a reboot.

For a live view of CPU and memory per worker, use pm2 monit.

Step 6 - Deploying updates with zero downtime

In cluster mode, pm2 reload restarts workers one at a time and waits for each new worker to come online before stopping the next old one, so the app keeps accepting connections during a deploy. A typical update from a Git checkout looks like this:

cd ~/apps/my-api
git pull
npm ci --omit=dev
pm2 reload ecosystem.config.js --env production --update-env

npm ci installs exactly what package-lock.json specifies, --omit=dev skips development dependencies, and --update-env makes PM2 apply any environment variables you changed in the ecosystem file.

To test the reload, run a request loop in a second SSH session:

while true; do curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000; sleep 0.2; done

Run pm2 reload my-api in the first session. The loop should keep printing 200 without any connection errors. Stop it with CTRL+C.

Step 7 - Publishing the app through Nginx

The app listens only on 127.0.0.1, so it is not reachable from outside. Nginx will accept public traffic on port 80 and proxy it to PM2. Install Nginx:

sudo apt install nginx

Create a server block:

sudo nano /etc/nginx/sites-available/my-api
server {
    listen 80;
    server_name your_domain;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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 "upgrade";
    }
}

Replace your_domain with your domain name, or with your_server_ip if you do not have one. The Upgrade and Connection headers let WebSocket connections pass through.

Enable the site, test the configuration and reload Nginx:

sudo ln -s /etc/nginx/sites-available/my-api /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

If UFW is active, allow HTTP and HTTPS:

sudo ufw allow 'Nginx Full'

From your local machine, open http://your_domain. You should see the JSON response with "env":"production". To add HTTPS, install Certbot with sudo apt install certbot python3-certbot-nginx and run sudo certbot --nginx -d your_domain.

Troubleshooting

The app shows errored or keeps restarting in pm2 ls. Read the error log, which usually contains the stack trace:

pm2 logs my-api --err --lines 50

A common cause is EADDRINUSE, meaning another process already uses port 3000. Find it with sudo ss -ltnp 'sport = :3000'.

Apps do not come back after a reboot. Check that the unit is enabled with systemctl is-enabled pm2-your_user and that you ran pm2 save after the last change. If you upgraded Node.js or moved PM2, regenerate the unit with pm2 unstartup systemd, then repeat Step 4.

pm2 ls shows no apps, but the site still works. You are probably looking at another user's PM2 daemon, for example after running sudo pm2. Each user has a separate daemon in their own ~/.pm2. Always run PM2 as the user that owns the app.

Nginx returns 502 Bad Gateway. Nginx cannot reach the app. Confirm it answers locally with curl http://127.0.0.1:3000 and check sudo tail -n 20 /var/log/nginx/error.log.

Conclusion

Your Node.js app now runs under PM2 in cluster mode, starts automatically at boot through a systemd unit, rotates its logs and accepts public traffic through Nginx. You also have a repeatable deploy procedure that reloads workers without downtime.

As next steps you can:

  • Secure the site with a Let's Encrypt certificate and redirect HTTP to HTTPS.
  • Move secrets out of the ecosystem file into environment variables loaded from a file only your user can read.
  • Automate the Step 6 commands from your CI pipeline over SSH.