systemd or pm2 for a small Node app on one VPS?
· 8 min read
On this page (11)
Both systemd and pm2 keep a Node process alive and bring it back when it crashes. For a single app on a single server the choice matters less than the details around it, and one detail can make a deploy silently do nothing. We ran both in clean Debian 12 containers to see how they behave on the things that bite small projects: release switches, restarts, secrets, memory limits and logs.
Short version: we would pick systemd for one app on one box, and we would not argue with someone who already runs pm2 and likes it. The rest of this post is the reasoning, so you can decide for your case.
One app, one box
Who needs this
The decision comes up in a few recognisable situations:
- A new side project on a fresh VPS. You have a Node app that works locally, a Debian box, and no process manager. You want it to start on boot and restart on crash, and you want to avoid installing more than you have to.
- An app you inherited. It runs under pm2 as root, started by hand years ago, with environment variables exported in somebody's shell history. You want to know whether migrating to systemd is worth an afternoon.
- A team that deploys with release folders. You switch a
currentsymlink and restart. Whether that actually works depends on the process manager, and finding out in production is unpleasant. - A small agency hosting several client apps. One box, a few Node processes, each with its own secrets and memory appetite. You want isolation without containers.
What we verified for pm2
It pins the working directory of the first start. With release folders and a current symlink, we switched the symlink and ran pm2 reload app, then pm2 restart app. pm2 kept running the old release, because it keeps the working directory the process had when it was first started, and that is the resolved path of the old release, not the symlink. A deliberately broken release was reported healthy, since the old code was still answering. What works is pm2 delete app followed by pm2 start from inside current/.
This is the one that causes real damage. A health check that only asks "is something answering" passes, the deploy script prints success, and the new code never runs. Anyone who deploys with symlinks and pm2 should verify that the version in the response changes after a deploy.
The cost of the workaround. Deploying by delete and start meant roughly a quarter of requests failed during the deploy: 32 of 116 in one run, and 31 to 35 failed across several runs, at 20 requests per second through Caddy. Blue/green with two ports and a graceful Caddy reload brought that to zero failed requests in every run. The measurements and the script are in zero-downtime deploys on one VPS with Caddy and pm2.
pm2 does have real strengths. Setup is one command, pm2 ls shows every process with memory and restarts, pm2 logs tails output with colours, and cluster mode can run several workers of the same app. Those are the reasons people choose it.
The pm2 symlink trap
Rule of thumb: after any deploy, check that the version in the response actually changed.
What we verified for systemd
Here is a unit file for a small Node app:
# /etc/systemd/system/app.service
[Unit]
Description=App
After=network.target
[Service]
User=app
WorkingDirectory=/var/www/app/current
EnvironmentFile=/etc/app.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=2
# a runaway app is killed alone instead of the OOM killer picking a victim
MemoryMax=512M
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
Install it with:
sudo systemctl daemon-reload
sudo systemctl enable --now app
systemd-analyze verify /etc/systemd/system/app.service
What we observed with systemd 252 as PID 1:
Restart=on-failurebrought the service back afterkill -9, within seconds.- After switching the
currentsymlink,systemctl restart apppicked up the new release. systemd resolvesWorkingDirectorywhen the process starts. This is the behaviour pm2 did not give us. systemd-analyze verifyprinted nothing for the unit, which is how it says the file is fine.MemoryMax=512Mbecame the cgroup'smemory.max(536870912 bytes), so a process over the limit is killed by the kernel inside its own cgroup and the rest of the machine is left alone.- An
EnvironmentFileowned by root with mode 600 kept secrets out of the repository and out of the unit file. systemd reads it as root before dropping privileges, and the unprivilegedappuser cannot read it. - The service came back after a container restart, with systemd as PID 1.
Secrets and environment
With pm2, environment lives in ecosystem.config.js under env, or in the shell that ran pm2 start. The first tends to get committed with secrets in it. The second is lost when someone starts the app from a different shell, and pm2 caches the environment from the first start unless you pass --update-env. We have seen a rotated database password that "did not take" for exactly that reason.
With systemd, the file /etc/app.env holds KEY=value lines, one per line, no export, no quotes needed in simple cases:
sudo install -m 600 -o root -g root /dev/null /etc/app.env
sudoedit /etc/app.env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://app:secret@127.0.0.1/app
To rotate a secret you edit the file and run sudo systemctl restart app. The file is outside every release folder, so a deploy cannot overwrite it and a git clone cannot expose it.
Logs
pm2 writes each app's output to files under ~/.pm2/logs/, which grow until you install the pm2-logrotate module or clean them up. systemd sends stdout and stderr to the journal:
journalctl -u app -f # follow
journalctl -u app --since "1 hour ago"
journalctl -u app -p err # errors only
The journal has a size cap you set once, which is the difference between a log that rotates itself and a full disk. The details are in journald and logrotate: stop the disk filling up. If your Node app logs to stdout, which it should, you need no log file handling at all under systemd.
Memory limits
Memory limits, innermost first
Node does not know about your cgroup. Under MemoryMax=512M the process can grow past what V8 considers its heap limit and get killed without a stack trace in your logs. Set --max-old-space-size below the limit so Node fails with a readable error first:
ExecStart=/usr/bin/node --max-old-space-size=384 server.js
pm2 offers max_memory_restart: "300M" in the ecosystem file, which restarts the app when it passes the size. It is convenient and it papers over leaks politely. Whichever you use, a restart because of memory should show up in your monitoring, not stay silent.
The comparison
| systemd | pm2 | |
|---|---|---|
| Extra software to install and update | None, already PID 1 | Node package plus its own daemon |
| Start on boot | systemctl enable | pm2 startup then pm2 save |
| Restart after crash | Restart=on-failure | Built in |
Follows a current symlink after restart | Yes, resolved at start | No, keeps the first working directory |
| Secrets | Root-owned EnvironmentFile | Ecosystem file or shell environment |
| Memory limit | MemoryMax, enforced by the kernel | max_memory_restart, enforced by pm2 polling |
| Logs | journald, one cap for everything | Files per app, rotation via a module |
| Process list and live dashboard | systemctl status, systemd-cgtop | pm2 ls, pm2 monit |
| Cluster mode | No, run instances on several ports | Yes |
| Not Node-specific | Works for Go, Python, anything | Works, but Node is its home |
Our pick, and why
For one app on one server we would use systemd. There is one fewer daemon to keep alive and upgrade, the process manager is the same one that manages everything else on the machine, and the symlink-release layout works the way you expect. Hardening options such as NoNewPrivileges, ProtectSystem and PrivateTmp come free with the unit file, as do per-service memory limits. If you later add a Go service or a Python worker, it gets the same treatment.
Reasons to pick pm2 are real, though. You already know it. You want its process list and log commands. You rely on cluster mode. Or you run a lot of small Node processes and like one tool for all of them. That is an opinion, not a measurement.
Making either choice safer
- Put zero-downtime switching in front of it. Whichever manager you choose, a plain restart refuses connections for the length of the start-up. Two ports and a reverse proxy remove that gap. See what a plain systemctl restart costs under load for the numbers.
- Run the app as an unprivileged user. Not root. The unit file's
User=line does it in one place. With pm2, start the daemon as that user as well. - Harden the box once. Process managers do not protect you from an open SSH port. Harden a fresh Debian VPS covers the basics.
- Write the restore test before you need it. A process manager keeps the app running. It does nothing for the data.
Mistakes we see
- Running pm2 as root from a shell and forgetting
pm2 save. After a reboot the process list is empty, or restored from a stale save. pm2 reloadon a symlink layout. Described above: the deploy passes, nothing changes.- Expecting
MemoryMaxto make the app handle memory gracefully. It kills. Set Node's heap flag lower than the limit. - Putting secrets in the unit file. Unit files are world-readable under
/etc/systemd/system/. UseEnvironmentFilewith mode 600, and remember thatsystemctl show appprintsEnvironment=values. - Skipping
StartLimitthinking. A crash-looping app uses up systemd's start limit (by default 5 starts in 10 seconds), and after thatsystemctl restartis refused until you runsystemctl reset-failed app. Know that before your rollback depends on it. - Two process managers for one app. An app enabled in systemd and also in the pm2 resurrect list starts twice on boot, and the second one fails to bind the port.
FAQ
Is systemd better than pm2 for Node?
For a single app on a single server we think so, mostly because it is already there and it follows symlink releases. pm2 is better when you want its dashboard, its log commands or cluster mode. Neither is wrong; the cost of switching is an afternoon.
Can I use both?
You can, but not for the same app. Some people keep pm2 for local development and systemd on the server, which works fine. Running one app under both leads to double starts and port conflicts.
How do I make a systemd Node app start on boot?
Put WantedBy=multi-user.target in the [Install] section and run sudo systemctl enable app. enable --now also starts it immediately. After the next reboot, systemctl status app shows it running.
Does systemd restart my app if it hangs rather than crashes?
Not by default. Restart=on-failure reacts to exits. For hangs, give the service a watchdog (WatchdogSec= together with sd_notify pings from the app) or have an external health check restart it. Most small apps rely on an external check.
Will pm2 follow my symlink if I use pm2 start instead of reload?
A fresh pm2 start from inside current/ picks up the new release, because the process then starts in the new directory. The trouble is reload and restart on an existing process, which keep the original directory.
The unit file, the symlink deploy and rollback, the memory limit and the other checks here were run as scripts in a container, and they ship with the Small App on One VPS handbook, including a command to re-run every check.