Run your app with systemd, not screen or nohup: units, restarts, timers
Stop leaving apps running in screen or nohup. A systemd unit with its own user, restart on failure, env from a file, basic hardening, and timers instead of cron.
- A unit file of a few lines gives you what screen and nohup never will: start at boot, restart on crash, logs in the journal
- Restart=on-failure needs RestartSec and an understanding of StartLimitBurst, or a crash-looping app is abandoned after 5 starts in 10 seconds
- ProtectSystem=strict plus ReadWritePaths lets the app write only where you allow; the rest of the machine is read-only to it
- A timer with Persistent=true runs a job it missed while the machine was off as soon as it boots; cron cannot
If an app on your server still lives in screen or nohup ... &, it will not come back after a reboot, will not restart when it crashes, and writes to a log file nobody rotates. systemd is already on every Ubuntu machine. A unit file under 20 lines fixes all three, narrows what the app is allowed to touch, and gives you timers to replace cron.
Tested on Ubuntu 24.04 with systemd 255 (package 255.4-1ubuntu8). Every default quoted here was checked against the systemd 255 man pages.
screen, nohup, pm2 and systemd compared
| screen / nohup | pm2 | systemd | |
|---|---|---|---|
| Starts at boot | no | via pm2 startup (writes a systemd unit) | yes (enable) |
| Restarts on crash | no | yes | yes (Restart=) |
| Runs as its own user | whoever typed it | whoever runs pm2 | User= |
| Logs | file/terminal, never rotated | pm2’s own files | journal, size-capped, searchable by time |
| Restricts system files | no | no | ProtectSystem=, ReadWritePaths= |
| Extra install | no | Node + pm2 | no, ships with the OS |
pm2 still has its place, Node cluster mode for one. For a single-process app, systemd does it all with no middle layer.
Your first unit file
Say the app lives in /srv/myapp and runs with node server.js. First create a system user that cannot log in:
# on the server
sudo useradd --system --home /srv/myapp --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /srv/myapp
Then write /etc/systemd/system/myapp.service:
[Unit]
Description=My app (HTTP API)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node /srv/myapp/server.js
Restart=on-failure
RestartSec=5
# hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/myapp/data
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
enable hooks the unit into multi-user.target so it starts at boot; --now starts it immediately. With no Type= and an ExecStart=, systemd uses simple: the process it launches is the service. Do not let the app fork itself into the background (no &, no daemon mode).
EnvironmentFile: keep secrets out of the unit
Anyone on the machine can read a unit file (systemctl cat prints it). Do not put passwords in Environment=; move them to a separate file:
sudo mkdir -p /etc/myapp
sudo tee /etc/myapp/env >/dev/null <<'EOF'
# lines starting with # are ignored
PORT=3000
DATABASE_URL=postgres://myapp:change-me@127.0.0.1/myapp
EOF
sudo chmod 600 /etc/myapp/env
One KEY=value per line. systemd, running as root, reads the file before starting the process and passes the values in as environment, so the app itself never needs read access to it. Prefix the path with - (EnvironmentFile=-/etc/myapp/env) if a missing file should not be an error. For keeping each system’s credentials apart, see the separate-credentials article.
Restarts: why RestartSec and StartLimit go together
Restart=on-failure restarts when the process exits non-zero, is killed by a signal (other than SIGHUP, SIGINT, SIGTERM or SIGPIPE), times out, or trips the watchdog. A clean exit 0 is not restarted.
The trap is two defaults that work as a pair:
RestartSec=defaults to 100 ms- the rate limit allows at most
StartLimitBurst=5 starts withinStartLimitIntervalSec=10 seconds (both set in[Unit]). Past that, systemd stops restarting and the service stays failed
Worked example: the app dies immediately on start because the database is not up yet.
- Defaults: starts at 0, 0.1, 0.2, 0.3, 0.4 s. Five starts in 0.4 s hits the limit and the service is failed for good, even though the database would have been ready 3 seconds later
RestartSec=5alone: starts at 0, 5, 10 s. Any 10-second window holds at most 3 starts, never 5, so it never hits the limit and loops every 5 seconds forever. If the cause is a bad config, your journal fills with the same errorRestartSec=5+StartLimitIntervalSec=300+StartLimitBurst=5(the file above): starts at 0, 5, 10, 15, 20 s; the fifth start inside 300 s makes it give up at about 20 s. If the database was merely slow, the second or third attempt succeeds
RestartSec decides how long to wait; StartLimit decides when to give up. Always set both.
Once it hits the limit, fix the cause, then sudo systemctl reset-failed myapp before starting again. To hear about a real outage, wire up an alert as in the Telegram monitoring article.
Basic hardening
| directive | what it does |
|---|---|
NoNewPrivileges=true | the process and its children cannot gain privileges via setuid/setgid or file capabilities |
ProtectSystem=strict | the whole filesystem is read-only except /dev, /proc, /sys |
ReadWritePaths= | the folders that stay writable under strict |
ProtectHome=true | /home, /root, /run/user appear empty and inaccessible |
PrivateTmp=true | its own /tmp and /var/tmp, removed when the service stops |
(For comparison: ProtectSystem=true locks only /usr and the boot directories, full adds /etc, strict locks everything.)
If the app is compromised, the attacker is user myapp and can write only to /srv/myapp/data; system files and the app’s own code are off-limits. Check the score with:
systemd-analyze security myapp
It walks every directive and gives an exposure score, lower is better. Treat it as a checklist of what else you could close, one at a time, testing the app after each; don’t chase zero. For data folders there is also StateDirectory=myapp: systemd creates /var/lib/myapp, owned by User=, and writable even under strict.
Timers instead of cron
Scheduled jobs (backups, cache cleanup) are a service + timer pair with the same name.
/etc/systemd/system/myapp-backup.service:
[Unit]
Description=Nightly backup for myapp
[Service]
Type=oneshot
User=myapp
ExecStart=/srv/myapp/bin/backup.sh
/etc/systemd/system/myapp-backup.timer:
[Unit]
Description=Run myapp backup every night
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=15min
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-backup.timer
systemctl list-timers myapp-backup.timer
systemd-analyze calendar "*-*-* 03:00:00" # see what the expression means
The timer finds the service with the same name by default (Unit=). What you get over cron:
Persistent=true(defaultfalse) stores the last run time on disk. If the machine is off at 03:00 and boots at 07:00, the job runs right after boot. cron simply skips itRandomizedDelaySec=15min(default 0) adds a random 0-15 minute delay, so many machines with the same schedule don’t hit your storage at the same moment- Job output goes to the journal, no
>> /var/log/x.log 2>&1, andsystemctl status myapp-backuptells you whether the last run succeeded
Watch AccuracySec=, which defaults to 1 minute: systemd may shift the wake-up by up to a minute to batch timers. Set AccuracySec=1s if the job must fire on the second.
Reading logs with journalctl
A service’s stdout and stderr go to the journal by default; no log files to manage yourself.
journalctl -u myapp -f # follow live
journalctl -u myapp -b # since this boot
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -p err # errors and worse only
journalctl -u myapp -n 50 --no-pager # last 50 lines
The journal has its own size cap; for keeping logs from filling the disk see the disk-full article.
Common mistakes
- Editing a unit and forgetting
daemon-reload. systemd keeps the old version;systemctl statuswarns that the unit file changed on disk ~,$(...)or pipes inExecStart=. The command does not go through a shell, so none of these work. Use full paths; if you need a pipe or&&, call/bin/sh -c '...'or move it into a scriptexportin the EnvironmentFile. It is not a shell script; lines must be plainKEY=value- The app breaks under strict with read-only errors. Look in
journalctl -u myappforEROFSorRead-only file systemand add only that path toReadWritePaths= - StartLimit settings in
[Service]. In current systemd they belong in[Unit] - A timer that was never enabled. Creating the file does nothing on its own; check with
systemctl list-timers --all
If you deploy by swapping folders as in the atomic deploy article, point WorkingDirectory= and ExecStart= at the fixed path that gets swapped (not a versioned folder) and systemctl restart myapp after the swap.
Sources
FAQ
I already use pm2. Do I have to switch?
No. pm2 has real strengths such as cluster mode, but making pm2 start at boot goes through pm2 startup, which writes a systemd unit anyway. For a single-process app, using systemd directly removes a layer and gives you hardening and the journal for free.
I edited the unit file and nothing changed.
Run sudo systemctl daemon-reload, then restart the service. systemd reads unit files on reload, not on every start.
My app crash-looped and now shows start-limit-hit. What now?
Fix the cause first (journalctl -u yourservice), then run sudo systemctl reset-failed yourservice to clear the counter, and start it again.
Commands and settings were tested on sample systems. Try them on a test machine before production.