Making PM2 survive a server reboot (both halves)

The server rebooted and the site did not come back. It came back an hour later, when somebody pushed a deploy. That looks like a deployment problem and it is not one — the deploy simply ran pm2 start again by hand.

Booting your app needs two separate things, and each one is useless without the other.

The two halves

pm2 save writes the current process list to ~/.pm2/dump.pm2. PM2 relaunches what that file contains and nothing else. A deploy script that does pm2 delete and pm2 start but never saves leaves the dump stale — or empty, if it was never written at all.

pm2 startup installs a systemd unit that runs pm2 resurrect at boot, which reads that dump.

One without the other gets you a boot that starts nothing, or a saved list nobody ever reads.

The trap

Running pm2 startup as an unprivileged user installs nothing at all. It detects systemd, works out the command that would install the unit, prints it, and exits. The output is long, green, and ends with a line about the command being copy-pasteable. It reads like success. Nothing happened.

So: run it as root, and verify with systemd rather than with what PM2 printed.

The correct order

The app must be online before you save, or you save an empty list.

# as <user> — confirm the app is running
pm2 status
# as root — install the unit for that user
env PATH=$PATH:/usr/bin pm2 startup systemd -u <user> --hp /home/<user>

The env PATH=$PATH:/usr/bin prefix is not decoration. Root's PATH usually has no Node in it, and the unit records the path it was generated with; without it the unit exists but cannot find node at boot.

# as <user> again — write the dump
pm2 save

Verify each half separately

They fail independently, so check them independently.

systemctl is-enabled pm2-<user>

That must print enabled. Anything else — disabled, Failed to get unit file state — means the unit was never installed, whatever PM2 told you.

grep -o '"name":"[^"]*"' /home/<user>/.pm2/dump.pm2

That must list <app>. If the file does not exist, or the grep prints nothing, pm2 save never ran with the app online.

Test it without rebooting

You do not need to reboot a production box to know whether this works.

# as <user>
pm2 kill
# as root
systemctl start pm2-<user>
# as <user>
pm2 status
curl -sf http://127.0.0.1:3000/health

If <app> is back and online, both halves work. That is exactly what boot does.

Never run pm2 save after pm2 kill. With the daemon killed, the process list is empty, and saving writes that emptiness to the dump — undoing the one thing you were testing. If you do it by reflex, start the app again and re-run pm2 save.

Put pm2 save in the deploy script

The dump drifts the moment a deploy changes the process. If your script deletes and restarts, it has to save afterwards:

pm2 delete <app> || true
pm2 start ecosystem.config.cjs --only <app>
pm2 save

The || true matters on a first run, where there is nothing to delete and pm2 delete exits non-zero — which kills the whole script under set -e.

Without that last line, everything above keeps working right up until the next deploy that renames the app, changes the instance count, or switches the entry point. Then the dump describes a process that no longer exists, and the next reboot resurrects it.

One more thing about boot order

The generated unit is After=network.target. It is not ordered after your database. On a cold boot the app can start before MariaDB or PostgreSQL is accepting connections, fail its first database call, and exit.

Usually this resolves itself: PM2 restarts the process, the database is up by the second or third attempt, and the site comes online. The tell is a restart counter above zero on a process that is otherwise healthy — pm2 status shows it in the column.

If it does not recover, then order the unit explicitly:

sudo systemctl edit pm2-<user>
[Unit]
After=network.target mariadb.service

Only add that if you have watched it fail. Ordering a unit after a service that is slow, or that lives on another host, buys you a boot that hangs instead of one that retries.

Check your site · More from the blog · About AgentReady