How to write a systemd service for your app
Run your own program as a systemd service that starts at boot, restarts when it crashes, runs as its own user, reads an env file and is sandboxed.
Tested on Ubuntu 24.04 LTS, Debian 13 Stable, AlmaLinux 10
Before you start
- A Linux server with systemd (any current Ubuntu or Debian or AlmaLinux or Rocky or Fedora or Arch)
- Root or a user with sudo
- A program that runs in the foreground
To run your app as a systemd service, write a unit file at /etc/systemd/system/myapp.service with an ExecStart= line that starts it in the foreground, a User= to run it as, Restart=on-failure, and WantedBy=multi-user.target; then run systemctl daemon-reload and systemctl enable --now myapp. systemd then starts it at boot, restarts it if it crashes, and collects its output in the journal.
This tutorial builds that file step by step, with an environment file for secrets and the sandboxing options that are safe for most apps. The same unit ran unchanged on Ubuntu 24.04 (systemd 255), Debian 13 (systemd 257) and AlmaLinux 10 (systemd 257).
The example app is a small Python web server in /opt/myapp. Swap in your own command: a Node.js server.js, a Go binary, a Gunicorn or Puma process. The one rule is that it must stay in the foreground; don't use a "daemonize" or & option.
Create a user for the app
An app that runs as root can do anything root can if it's compromised. Give it a system user with no login shell:
sudo useradd --system --home-dir /opt/myapp --shell /usr/sbin/nologin myapp
sudo install -d -o myapp -g myapp /opt/myappPut your app's files in /opt/myapp. If the app only reads its code, leave the files owned by root and readable by everyone; it's safer when the app can't rewrite itself.
Put settings and secrets in an environment file
Keep configuration out of the unit file, so you can change it without editing the unit, and so secrets aren't world-readable:
sudo install -d -m 750 /etc/myapp
sudo tee /etc/myapp/myapp.env <<'EOF'
PORT=8000
SECRET_KEY=change-me
EOF
sudo chown root:myapp /etc/myapp/myapp.env
sudo chmod 640 /etc/myapp/myapp.envThe format is one NAME=value per line. No export, and no shell expansion: $HOME stays the literal text $HOME.
Write the unit file
Create /etc/systemd/system/myapp.service:
[Unit]
Description=My app
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/python3 /opt/myapp/server.py
Restart=on-failure
RestartSec=5
StateDirectory=myapp
[Install]
WantedBy=multi-user.targetWhat each part does:
| Setting | Why |
|---|---|
After= / Wants=network-online.target |
Start once the network is up, for apps that connect to a database or an API at startup. |
Type=simple |
The process in ExecStart is the service. Right for almost every app that runs in the foreground. |
User=, Group= |
Run as myapp, not root. |
EnvironmentFile= |
Load the variables from the file above. |
ExecStart= |
The command, with a full path. systemctl doesn't use your shell's PATH; find the path with command -v python3 (or node, gunicorn). |
Restart=on-failure |
Restart after a crash or a non-zero exit, but not after a clean systemctl stop. always also restarts after a clean exit. |
RestartSec=5 |
Wait five seconds between restarts. |
StartLimitIntervalSec=60, StartLimitBurst=5 |
If it fails five times within 60 seconds, stop trying, so a broken deploy doesn't loop forever. These two belong in [Unit]. |
StateDirectory=myapp |
Creates /var/lib/myapp owned by the service user, and passes its path in $STATE_DIRECTORY. Use it for files the app writes. |
WantedBy=multi-user.target |
What enable hooks into: the normal boot. |
Check the file for mistakes before loading it:
sudo systemd-analyze verify /etc/systemd/system/myapp.serviceIt prints nothing when the file is fine, and names the line when it isn't.
Start it, and start it at boot
sudo systemctl daemon-reload
sudo systemctl enable --now myappdaemon-reload makes systemd read the new file. enable --now starts the service and links it into the boot.
Check it worked
systemctl status myapp
curl -I http://127.0.0.1:8000/status shows Active: active (running), the main process and the last few log lines. To test the restart policy, kill the process the hard way and look again a few seconds later:
sudo kill -9 "$(systemctl show -p MainPID --value myapp)"
systemctl show -p NRestarts,ActiveState myappNRestarts=1 and ActiveState=active: systemd brought it back.
Read its logs
Anything the app prints to standard output or standard error goes to the journal:
journalctl -u myapp -f # follow live
journalctl -u myapp -n 100 # the last 100 lines
journalctl -u myapp --since todayThere's no log file to rotate. Read logs with journalctl covers filters, persistence and size limits.
Sandbox it
systemd can take privileges away from a service in a few lines. These are safe for a typical web app that writes only to its StateDirectory; add them to [Service]:
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yesProtectSystem=strict makes the whole file system read-only for the service except /dev, /proc, /sys and the directories systemd creates for it (StateDirectory= and friends). If your app writes somewhere else, allow that path explicitly:
ReadWritePaths=/srv/uploadsProtectHome=yes hides /home, /root and /run/user. PrivateTmp=yes gives the service its own /tmp. NoNewPrivileges=yes stops it gaining privileges through sudo or setuid programs.
Reload and restart, then let systemd score the result:
sudo systemctl daemon-reload
sudo systemctl restart myapp
systemd-analyze security myappThe score goes down as you add options; ours went to 7.4 ("MEDIUM") with the list above. Each line it prints is another option you could add. Add them one at a time and test the app after each: an app that needs something you took away fails in ways that can be hard to trace back.
Change settings later with a drop-in
To change one setting without editing the main file (useful when a package ships the unit), use a drop-in:
sudo systemctl edit myappIt opens an editor for /etc/systemd/system/myapp.service.d/override.conf. Put only the section and lines you're changing:
[Service]
RestartSec=10Save, then sudo systemctl restart myapp. systemctl cat myapp shows the main file and every drop-in, in the order they apply.
One trap: variables from EnvironmentFile= win over Environment= lines, wherever they are. An Environment=PORT=8001 in a drop-in doesn't change a PORT that the env file sets; change the env file instead.
Troubleshooting
Start request repeated too quickly. The app crashed more times than StartLimitBurst allows. Read the reason with journalctl -u myapp -n 50, fix it, then sudo systemctl reset-failed myapp and start it again.
status=203/EXEC. systemd couldn't run the ExecStart command: the path is wrong, the file isn't executable, or it's a script without a #! line. Use the full path from command -v.
status=217/USER. The User= doesn't exist. Create it, or fix the name.
Permission denied in the app's own log after adding sandbox options. The app writes somewhere ProtectSystem=strict made read-only. Add that path to ReadWritePaths= or move the data to the StateDirectory.
Changes to the unit file do nothing. Run sudo systemctl daemon-reload after every edit, then restart the service.
Next steps
- Run something on a schedule instead of all the time: cron vs systemd timers.
- Put nginx in front of the app for HTTPS: nginx and HTTPS.
- If the app is slow or using too much memory: troubleshoot high CPU and memory.
Doing it on VPSNine
Every VPSNine image uses systemd, so this unit file works the same on Ubuntu 24.04, Debian 13 and AlmaLinux 10. systemd watches the app; for the server as a whole, set resource alerts in the panel for CPU, memory and the server going down. See plans for memory and CPU per size.