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'withinstances: 'max'— the Step 3 behavior, now codifiedmax_memory_restart: '300M'— a safety net against slow memory leaks; PM2 recycles any instance that crosses the limitenvvsenv_production— one file, two profilesmerge_logs: true— all cluster instances write to one log file instead of fourtime: 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
pm2 startdetached the app from your SSH session-i maxclustered it across every core with built-in load balancingecosystem.config.jsmade the whole setup declarative and committablepm2 reloadgives zero-downtime deploys;pm2 logsplus logrotate keeps output manageablepm2 startupandpm2 savemade 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.