Run Your App as a systemd Service
21 Apr 2026PM2 and forever are fine, but if your app is already just a binary or a script, systemd does the same job — auto-restart, boot startup, log capture — with nothing extra installed. It’s already on the server.
The minimal unit file
# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
EnvironmentFile=/opt/myapp/.env
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
enable makes it start on boot. start starts it now. Both, every time — people run one and forget the other, then wonder why a reboot doesn’t bring the app back.
User=deploy, not root
Running your app as root means a code execution bug in your app is a code execution bug as root. Create a dedicated user with no login shell for this:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin deploy
sudo chown -R deploy:deploy /opt/myapp
Restart policy: match it to what actually failed
[Service]
Restart=on-failure
RestartSec=5
[Unit]
StartLimitIntervalSec=60
StartLimitBurst=5
StartLimitIntervalSec and StartLimitBurst belong in [Unit], not [Service] — put them under [Service] by mistake and modern systemd silently ignores them instead of erroring, so the crash-loop cap quietly doesn’t apply.
Restart=on-failure restarts on a crash or nonzero exit, not on a clean systemctl stop — you don’t want systemd fighting you when you deliberately stop the service. StartLimitBurst=5 within StartLimitIntervalSec=60 caps it at 5 restarts per minute; past that, systemd gives up and marks the service failed instead of crash-looping forever, same reasoning as PM2’s max_restarts.
Environment variables, two ways
Inline for a couple of values:
Environment=NODE_ENV=production
Environment=PORT=3000
A file for anything longer, so secrets aren’t sitting in a world-readable unit file:
sudo chmod 600 /opt/myapp/.env
sudo chown deploy:deploy /opt/myapp/.env
EnvironmentFile=/opt/myapp/.env
.env format here is plain KEY=value lines, no quotes, no export.
Logs: journalctl replaces log files entirely
systemd captures stdout/stderr automatically — no log file config needed:
journalctl -u myapp -f # follow live, like tail -f
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -n 100 # last 100 lines
If you want it to persist across reboots (default is often volatile, in-memory only):
sudo mkdir -p /etc/systemd/journald.conf.d
echo -e "[Journal]\nStorage=persistent" | sudo tee /etc/systemd/journald.conf.d/persistent.conf
sudo systemctl restart systemd-journald
Resource limits, so one runaway process doesn’t take the box down
MemoryMax=512M
CPUQuota=80%
MemoryMax triggers an OOM kill of just this service (which Restart=on-failure then restarts) instead of the kernel picking an arbitrary process to kill when the whole system runs out of memory — same safety-net reasoning as max_memory_restart in PM2, enforced at the OS level instead of the process manager level.
Check status without grepping ps
systemctl status myapp
Shows running/failed state, the last several log lines, uptime since last restart, and the PID — one command instead of cross-referencing ps aux and a log file.
Multiple apps, one pattern
sudo cp myapp.service myapp-worker.service
sudo systemctl edit myapp-worker # or edit the file directly, change ExecStart
sudo systemctl enable --now myapp-worker
Each service is an independent unit — a web process and a background worker as two separate .service files means you can restart, monitor, and resource-limit them independently, rather than one process manager juggling both as children of itself.
When to still reach for PM2
Systemd handles single Node processes fine but has no concept of Node’s cluster mode built in. If you want one command to fork N instances of the same app with load balancing across cores, that’s still PM2’s job — you can wrap pm2 start ecosystem.config.js --no-daemon in a systemd unit and get both: PM2’s cluster mode, systemd’s boot/restart supervision.