1. Write the Unit File
This runs an app from /opt/myapp as its own unprivileged user, starts it after the network is up, and restarts it five seconds after a crash. Save it as /etc/systemd/system/myapp.service:
[Unit] Description=My app After=network-online.target Wants=network-online.target [Service] Type=simple User=myapp WorkingDirectory=/opt/myapp ExecStart=/opt/myapp/venv/bin/python /opt/myapp/app.py EnvironmentFile=-/etc/myapp.env Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target
The - before the environment file path means “fine if it does not exist”. Create the user first, with no login shell:
sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin myapp sudo chown -R myapp: /opt/myapp
2. Start It and Enable It at Boot
sudo systemctl daemon-reload # after every change to the unit file sudo systemctl enable --now myapp # start now and at every boot systemctl status myapp journalctl -u myapp -f # follow its output
Anything the program prints goes to the journal, so journalctl -u myapp is the first place to look when something is wrong.
When It Will Not Start
systemctl status shows a line such as Main process exited, code=exited, status=203/EXEC. The number says which step failed before your program even ran. We reproduced each of these on Ubuntu 24.04 (systemd 255):
| Status | What the journal says | Cause and fix |
|---|---|---|
| 203/EXEC | Only status=203/EXEC | The file in ExecStart= does not exist, or is not executable. Check the path; chmod +x the file. |
| 203/EXEC | Failed to execute …: Exec format error | A script with no #! line. Add #!/bin/bash (or the right interpreter) as the first line. |
| 203/EXEC | Failed to execute …: No such file or directory (but the file exists) | Windows line endings: the #! line ends in \r, so the interpreter's name is wrong. Convert the file (below). |
| 200/CHDIR | Changing to the requested working directory failed | WorkingDirectory= does not exist. Create it or fix the path. |
| 217/USER | Failed to determine user credentials | User= names a user that does not exist. Create it with useradd. |
Checks for each 203 cause:
ls -l /opt/myapp/start.sh # exists? has x permission? head -1 /opt/myapp/start.sh # starts with #! ? head -1 /opt/myapp/start.sh | od -c # a \r before \n means Windows line endings sed -i 's/\r$//' /opt/myapp/start.sh # remove them
Two rarer causes: the script lives on a file system mounted with noexec (often /tmp), and on AlmaLinux or Rocky Linux an SELinux label that does not allow running the file, which restorecon -v on the file fixes after moving it to a normal location such as /usr/local/bin.
Running It on a Schedule Instead
For a job that should run every night rather than all the time, pair a service with a timer instead of using cron: a .timer unit with OnCalendar= starts the service on schedule and logs every run to the journal. Backing up a database to S3 uses exactly that pattern.