Deploys and releases

systemd or pm2 for a small Node app on one VPS?

· 8 min read

On this page (11)
  1. Who needs this
  2. What we verified for pm2
  3. What we verified for systemd
  4. Secrets and environment
  5. Logs
  6. Memory limits
  7. The comparison
  8. Our pick, and why
  9. Making either choice safer
  10. Mistakes we see
  11. FAQ

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

Two cards compare systemd, which is already installed and follows a moved current symlink on restart, with pm2, which has a nicer dashboard and cluster mode but keeps its first working directory.systemdAlready PID 1, nothing extra to updateRestart picks up the new current symlinkKernel-enforced MemoryMax, logs in journaldpm2reload keeps the first working directoryGood process list, log commands, cluster modeOwn daemon, log files need rotation

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 current symlink 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

A deploy moves the current symlink and runs pm2 reload, but pm2 keeps the old release directory, so the old code keeps answering and the health check still passes.Symlink movedcurrent to newreleasepm2 reloadkeeps first workingdirOld code answershealth check passesdeploy does nothing

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-failure brought the service back after kill -9, within seconds.
  • After switching the current symlink, systemctl restart app picked up the new release. systemd resolves WorkingDirectory when the process starts. This is the behaviour pm2 did not give us.
  • systemd-analyze verify printed nothing for the unit, which is how it says the file is fine.
  • MemoryMax=512M became the cgroup's memory.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 EnvironmentFile owned 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 unprivileged app user 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

Three stacked boxes: the Node heap flag fails first with a readable error, MemoryMax kills the process inside its own cgroup, and the rest of the machine is left alone.--max-old-space-size=384Node fails with a readable errorMemoryMax=512MKernel kills the process in its cgroupRest of the machineLeft alone

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

systemdpm2
Extra software to install and updateNone, already PID 1Node package plus its own daemon
Start on bootsystemctl enablepm2 startup then pm2 save
Restart after crashRestart=on-failureBuilt in
Follows a current symlink after restartYes, resolved at startNo, keeps the first working directory
SecretsRoot-owned EnvironmentFileEcosystem file or shell environment
Memory limitMemoryMax, enforced by the kernelmax_memory_restart, enforced by pm2 polling
Logsjournald, one cap for everythingFiles per app, rotation via a module
Process list and live dashboardsystemctl status, systemd-cgtoppm2 ls, pm2 monit
Cluster modeNo, run instances on several portsYes
Not Node-specificWorks for Go, Python, anythingWorks, 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 reload on a symlink layout. Described above: the deploy passes, nothing changes.
  • Expecting MemoryMax to 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/. Use EnvironmentFile with mode 600, and remember that systemctl show app prints Environment= values.
  • Skipping StartLimit thinking. A crash-looping app uses up systemd's start limit (by default 5 starts in 10 seconds), and after that systemctl restart is refused until you run systemctl 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.

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.

deployssystemdpm2nodevps