Skip to main content

Hands-On: Run an Express App with PM2

What We Are Building

You know the individual PM2 commands. This walkthrough chains them into the actual deployment routine: start an Express app, scale it across every CPU core, move the configuration into an ecosystem.config.js you can commit, and make the whole thing survive a server reboot.

Step 1: The App

A small Express app that reveals which process is serving each request — useful for watching cluster mode work:

// /home/ubuntu/myapp/index.js
const express = require('express');
const app = express();

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

app.listen(process.env.PORT || 3000);

NODE_APP_INSTANCE is injected by PM2 — instance 0, 1, 2, and so on. Confirm the app runs, then stop it:

cd /home/ubuntu/myapp
node index.js # check http://localhost:3000, then Ctrl+C

Step 2: First Start Under PM2

pm2 start index.js --name myapp
pm2 status

The app now survives you closing the SSH session. Hit it a few times:

curl http://localhost:3000
# {"pid":41231,"instance":"0"}

Same PID every time — a single fork-mode process. Let's fix that.

Step 3: Switch to Cluster Mode

Delete the fork-mode process and restart across all cores:

pm2 delete myapp
pm2 start index.js --name myapp -i max
pm2 status

On a 4-core server you'll see four rows, all named myapp. Now repeat the curl:

curl http://localhost:3000 # {"pid":41402,"instance":"1"}
curl http://localhost:3000 # {"pid":41409,"instance":"3"}
curl http://localhost:3000 # {"pid":41396,"instance":"0"}

Different PIDs — PM2 is load-balancing requests across instances on the same port, no code changes required. One caveat from experience: cluster mode assumes your app is stateless. In-memory sessions or local caches will behave inconsistently across instances; keep shared state in Redis or the database.

Step 4: Move Everything into an Ecosystem File

CLI flags don't belong in your deploy notes — they belong in a file in the repo. Generate a skeleton or just create it directly:

cd /home/ubuntu/myapp
nano ecosystem.config.js
module.exports = {
apps: [
{
name: 'myapp',
script: 'index.js',
instances: 'max',
exec_mode: 'cluster',
max_memory_restart: '300M',
env: {
NODE_ENV: 'development',
PORT: 3000,
},
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
error_file: '/home/ubuntu/.pm2/logs/myapp-error.log',
out_file: '/home/ubuntu/.pm2/logs/myapp-out.log',
merge_logs: true,
time: true,
},
],
};

The settings that earn their place:

  • exec_mode: 'cluster' with instances: 'max' — the Step 3 behavior, now codified
  • max_memory_restart: '300M' — a safety net against slow memory leaks; PM2 recycles any instance that crosses the limit
  • env vs env_production — one file, two profiles
  • merge_logs: true — all cluster instances write to one log file instead of four
  • time: true — prefixes every log line with a timestamp

Replace the ad-hoc process with the file-driven one:

pm2 delete myapp
pm2 start ecosystem.config.js --env production

Verify the environment took effect:

pm2 show myapp | grep -A2 env

Step 5: Work with Logs

pm2 logs myapp # live stream from all instances
pm2 logs myapp --lines 100 # last 100 lines
pm2 logs myapp --err # stderr only
pm2 flush myapp # truncate the log files

Because merge_logs and time are on, a crash investigation reads as one chronological stream. For long-running production apps, add log rotation so files don't grow forever:

pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 14

Step 6: Zero-Downtime Deploys

With cluster mode you get graceful deploys for free. After pulling new code:

pm2 reload myapp

reload restarts instances one at a time — while instance 0 restarts, the others keep serving traffic. Compare with pm2 restart myapp, which kills everything at once and drops requests for a second or two.

Step 7: Survive a Reboot

Two commands, in this order:

pm2 startup

PM2 prints a command tailored to your OS and user — something like:

sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u ubuntu --hp /home/ubuntu

Copy and run exactly what it printed. That registers a systemd service that resurrects the PM2 daemon at boot. Then freeze the current process list:

pm2 save

Test it for real — I recommend actually doing this once rather than trusting it:

sudo reboot
# ...reconnect after a minute...
pm2 status # myapp is online, all instances

Whenever you add or remove apps, run pm2 save again; the boot script restores whatever was last saved.

Recap

  1. pm2 start detached the app from your SSH session
  2. -i max clustered it across every core with built-in load balancing
  3. ecosystem.config.js made the whole setup declarative and committable
  4. pm2 reload gives zero-downtime deploys; pm2 logs plus logrotate keeps output manageable
  5. pm2 startup and pm2 save made it reboot-proof

From here, the typical production layout puts Nginx in front of this PM2-managed app as a reverse proxy on ports 80 and 443.