Node.js Production Setup With PM2 on a VPS

Published on | Updated

Run Node.js in production on a VPS with PM2: one ecosystem file, fork or cluster mode, start on boot, log rotation, reloads without downtime and monitoring.

A man with a tablet in a data center aisle between server racks full of network cables

Your Node.js app runs with node server.js in an SSH session, but that is not a production setup. On a VPS it has to start at boot, come back after a crash, use every vCore, keep its logs from filling the disk and take a new release without dropping requests. PM2 does all of that from one config file. This guide sets it up on Ubuntu 24.04 or Debian 12 with PM2 7.1.1, the current release on npm.

Quick answer: Install PM2 with sudo npm install -g pm2@latest and run your app as a normal user from an ecosystem.config.cjs file: cluster mode (instances: 'max') for an HTTP or TCP server, fork mode for workers and bots. Start it with pm2 start ecosystem.config.cjs --env production, run pm2 startup and the sudo line it prints, then pm2 save. Add pm2-logrotate, deploy with pm2 reload (without downtime only in cluster mode) and watch it with pm2 list, pm2 monit and pm2 logs.

Before You Start

PM2 7.1.1 needs Node.js 18 or later; step 1 of our Next.js deployment guide installs a current LTS release from NodeSource, which works for any app. Install PM2 once, create a user for the app and switch to it:

sudo npm install -g pm2@latest
sudo useradd --create-home --shell /bin/bash app
sudo -iu app

Each Linux user gets their own PM2 daemon, with its process list, logs and modules in ~/.pm2. Run every pm2 command below as app and without sudo: with the default sudo settings of Ubuntu and Debian, sudo pm2 uses root's ~/.pm2, a second PM2 with its own list. The code lives in /home/app/api. A Space-Node VPS is KVM with full root access, so this works as written.

Step 1: Write the Ecosystem File

An ecosystem file keeps your options in one place and in Git. pm2 init simple writes a starter ecosystem.config.js, but name yours ecosystem.config.cjs: PM2 reads .config.js, .config.cjs and .config.mjs files, and Node.js always loads .cjs as CommonJS, so module.exports still works when package.json says "type": "module". Save /home/app/api/ecosystem.config.cjs:

module.exports = {
  apps: [{
    name: 'api',
    cwd: '/home/app/api',
    script: 'dist/server.js',
    exec_mode: 'cluster',
    instances: 'max',
    max_memory_restart: '400M',
    wait_ready: true,
    listen_timeout: 10000,
    kill_timeout: 10000,
    merge_logs: true,
    time: true,
    env: {
      PORT: 3000,
    },
    env_production: {
      NODE_ENV: 'production',
    },
  }],
};

PM2 starts the app in cwd and resolves script from it. max_memory_restart restarts a worker that goes over that memory use (a reload in cluster mode), checked every 30 seconds; our guide to a PM2 app that keeps restarting helps you pick the value. Start the app:

cd ~/api
pm2 start ecosystem.config.cjs --env production
pm2 list

With several apps in the file, --only api limits a command to one. After changing exec_mode, run pm2 delete api and start from the file again: a reload keeps the mode the app started in.

Step 2: Fork Mode or Cluster Mode

Fork mode, the default, runs one process, like node server.js. Cluster mode uses the Node.js cluster module: the PM2 daemon holds the port and hands connections to the workers in turn (round-robin, the default on Linux), so several processes share one port without code changes. PM2's docs describe it for networked apps: HTTP(S), TCP and UDP servers. Queue workers and bots belong in fork mode, and PM2 falls back to it for Python or shell scripts.

'max' (or 0) starts a worker for every CPU that Node.js reports, on a VPS every vCore (node -p "require('node:os').cpus().length" shows the number). -1 leaves one free for nginx and a database, and a positive number starts that many. instances alone already switches a Node.js app to cluster mode, but write exec_mode too. A bot would run once per worker; our Discord bot PM2 guide keeps it in fork mode.

In cluster mode:

  • Keep state out of the workers. Each has its own memory, so in-memory sessions, caches and rate limits differ per worker; PM2's docs say to share state through Redis or a database.
  • Run scheduled jobs once. Each process gets a NODE_APP_INSTANCE number from 0 that stays unique after restarts and pm2 scale: if (process.env.NODE_APP_INSTANCE === '0') startJobs();. instance_var renames it if a library such as node-config reads that variable.
  • Count memory. Each worker is a full copy, so the memory column in pm2 list times the workers has to fit next to nginx and your database.

pm2 scale api 2 sets a running app to two workers; pm2 scale api +1 adds one.

Step 3: Set Environment Variables

PM2 layers each app's environment: the shell that runs pm2 start, then the file's env block, then, with --env production, env_production on top.

An app keeps the environment of its first start: pm2 restart api and pm2 reload api reuse it and print Use --update-env to update environment variables. To apply a change:

pm2 reload ecosystem.config.cjs --env production   # after editing the file
pm2 reload api --update-env                        # after changing your shell's variables
pm2 save

Reloading through the file applies its env blocks plus your shell's variables. --env needs the file: pm2 reload api --env production fails with Using --env [env] without passing the ecosystem.config.js does not work.

Keep secrets out of a file that goes into Git; our guide to several Node.js apps on one VPS loads a .env file per app with Node's --env-file, so those values never pass through PM2. Variables that PM2 does pass in, from the env blocks or your shell, are saved by pm2 save in ~/.pm2/dump.pm2, secrets included, and pm2 env 0 prints them. If ls -ld /home/app shows drwxr-xr-x, close it from your admin account with sudo chmod 750 /home/app.

Step 4: Start on Boot

pm2 startup

As app, this only prints a line that starts with sudo env PATH=$PATH: and ends with -u app --hp /home/app. Type exit, run that line from your admin account, return with sudo -iu app and save the list:

pm2 save

The line writes /etc/systemd/system/pm2-app.service and enables it. At boot the unit runs pm2 resurrect as app, which starts what pm2 save stored in ~/.pm2/dump.pm2, so save after every change.

Upgrades need one more step:

  • PM2: after sudo npm install -g pm2@latest, run pm2 update as app. It saves the list, starts a new daemon and brings your apps back, so expect a short restart.
  • Node.js: PM2's docs say to refresh the boot script: pm2 unstartup, then pm2 startup, running the sudo line each one prints. Then run pm2 update, because cluster workers use the Node.js of the daemon that is still running.

Step 5: Logs and Log Rotation

PM2 appends your app's output to ~/.pm2/logs/api-out.log and its errors to api-error.log. In cluster mode each worker gets its own pair with its PM2 id added, such as api-out-0.log, unless you set merge_logs: true. time: true timestamps every line.

pm2 logs api --lines 100        # last 100 lines, then follow new output
pm2 logs api --err --nostream   # last 15 error lines, then exit
pm2 flush api                   # empty the api log files

The files grow until something empties them. Install the pm2-logrotate module as app, with pm2 install, not npm install:

pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true
pm2 conf
SettingDefaultWhat it does
max_size10MRotates a file once it reaches this size (K, M, G)
retain30Rotated files to keep for each log file
compressfalseGzips rotated files
rotateInterval0 0 * * *Cron pattern for a forced rotation, midnight by default
workerInterval30Seconds between size checks

Version 3.0.0 copies a full file to a dated one, such as api-out__2026-10-31_00-00-00.log (plus .gz when compressed), then empties the original, so your app keeps writing. One catch in its source: for an app with several instances it only checks the first process, so files such as api-out-1.log never rotate. That is why the file above sets merge_logs: true.

Step 6: Reload Without Downtime

pm2 restart api stops a process before starting its replacement, so a single-process app has a gap. In cluster mode, pm2 reload api starts a new worker, waits until it is ready and then stops the old one, one or two workers at a time, so the others keep serving. In fork mode a reload is a plain restart. Build the release first (our Next.js deployment guide uses a folder per release behind a symlink), then:

pm2 reload ecosystem.config.cjs --env production
pm2 save

PM2 stops a worker with SIGINT and sends SIGKILL after kill_timeout, 1,600 ms by default. And without wait_ready, a new worker counts as ready once it listens, even before its database connection is up. Handle both in your entry file; with Express, app.listen() returns the Node.js server:

const server = app.listen(Number(process.env.PORT) || 3000, '127.0.0.1', () => {
  // connect to your database before this point
  if (process.send) process.send('ready');
});

process.on('SIGINT', () => {
  server.close(() => {
    // close database pools and queues here
    process.exit(0);
  });
});
  • process.send('ready') tells PM2 the worker can take traffic. PM2 waits for it up to listen_timeout (3,000 ms by default, 10 seconds in the file), then stops the old worker anyway. process.send only exists when a parent such as PM2 started the process, hence the check.
  • server.close() refuses new connections, closes idle keep-alive connections and calls back once open requests are done. Set kill_timeout above your slowest request. WebSockets keep the server open, so close them in the handler too, or the worker is killed when kill_timeout runs out.

Does nginx return 502 during deploys? Our nginx 502 guide covers that gap.

Step 7: Monitor Your Apps

pm2 list
pm2 show api
pm2 monit

pm2 list shows each process with its mode, status, restart count (↺), CPU and memory. pm2 show api adds the uptime, unstable restarts, script path, log files and exec mode, and pm2 monit is a live terminal view of CPU, memory and logs.

A restart count that keeps climbing means crashes or max_memory_restart; our PM2 restart guide reads PM2's own log to tell which. A VPS that is down can't report itself, so add an outside check that requests a page every few minutes. PM2 Plus, PM2's paid web dashboard, adds email and Slack notifications and has no free plan (pricing page, checked 31 October 2026); the commands above are free and open source.

FAQ

Should I use PM2 or a systemd service? For one process without cluster mode, a systemd unit is enough; our Telegram bot guide shows one for a Node.js bot. PM2 adds cluster mode with reloads, log rotation and one command line for all your apps, and it starts at boot through systemd itself.

Does PM2 cluster mode work with Socket.IO? Yes, with two changes from its docs. Every request of a client's session has to reach the same process, so let the client use WebSocket only (transports: ["websocket"]), which needs no sticky sessions, or run one instance. And a broadcast only reaches clients on the same worker unless you add an adapter, such as the Redis adapter.

Why are my apps gone after a reboot? Either the sudo line from pm2 startup never ran, or the list changed after your last pm2 save. Check journalctl -u pm2-app, start the apps and run pm2 save. With nothing running, pm2 save refuses to overwrite the saved list (PM2 is not managing any process, skipping save...) unless you add --force.

Do I have to run PM2 as root to serve ports 80 and 443? No. Keep the app on a port such as 3000, bound to 127.0.0.1 as in the code above, and let nginx take ports 80 and 443. Our nginx reverse proxy guide sets that up with a Let's Encrypt certificate.

Sources

All opened on 31 October 2026: