# 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.

Source: https://vpsnine.com/tutorials/create-a-systemd-service · Updated: 2026-10-21

Difficulty: intermediate · Time: about 20 minutes · Tested on: Ubuntu 24.04 LTS, Debian 13 Stable, AlmaLinux 10

Prerequisites:

- 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](https://vpsnine.com/tutorials/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

- Run something on a schedule instead of all the time: [cron vs systemd timers](https://vpsnine.com/tutorials/cron-vs-systemd-timers).
- Put nginx in front of the app for HTTPS: [nginx and HTTPS](https://vpsnine.com/help/nginx-and-https).
- If the app is slow or using too much memory: [troubleshoot high CPU and memory](https://vpsnine.com/tutorials/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](https://vpsnine.com/os/ubuntu-24.04), [Debian 13](https://vpsnine.com/os/debian-13) and [AlmaLinux 10](https://vpsnine.com/os/almalinux-10). systemd watches the app; for the server as a whole, set [resource alerts](https://vpsnine.com/help/notifications-and-alerts) in the panel for CPU, memory and the server going down. See [plans](https://vpsnine.com/plans) for memory and CPU per size.
