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:

bash
sudo useradd --system --home-dir /opt/myapp --shell /usr/sbin/nologin myapp
sudo install -d -o myapp -g myapp /opt/myapp

Put 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:

bash
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.env

The 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:

ini
[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.target

What 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:

bash
sudo systemd-analyze verify /etc/systemd/system/myapp.service

It prints nothing when the file is fine, and names the line when it isn't.

Start it, and start it at boot

bash
sudo systemctl daemon-reload
sudo systemctl enable --now myapp

daemon-reload makes systemd read the new file. enable --now starts the service and links it into the boot.

Check it worked

bash
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:

bash
sudo kill -9 "$(systemctl show -p MainPID --value myapp)"
systemctl show -p NRestarts,ActiveState myapp

NRestarts=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:

bash
journalctl -u myapp -f            # follow live
journalctl -u myapp -n 100        # the last 100 lines
journalctl -u myapp --since today

There'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]:

ini
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes

ProtectSystem=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:

ini
ReadWritePaths=/srv/uploads

ProtectHome=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:

bash
sudo systemctl daemon-reload
sudo systemctl restart myapp
systemd-analyze security myapp

The 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:

bash
sudo systemctl edit myapp

It opens an editor for /etc/systemd/system/myapp.service.d/override.conf. Put only the section and lines you're changing:

ini
[Service]
RestartSec=10

Save, 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

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.

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee