KnowledgebaseLinux VPS › Writing your first systemd unit — the practical 101

Writing your first systemd unit — the practical 101

Every modern Linux distro uses systemd as PID 1; sooner or later you need to run your own service under it (a custom Python app, a Go daemon, a backup script). The Internet's systemd tutorials run from one-paragraph (won't help when something breaks) to multi-thousand-word (you'll never read them). This is the in-between: enough to write a working unit, enough to debug it, no aspirational completeness.

Where unit files live

  • /lib/systemd/system/ — units shipped by packages. Don't edit these.
  • /etc/systemd/system/ — your custom units, or overrides for package-shipped ones. This is where your file goes.
  • ~/.config/systemd/user/ — user-mode units (run when a specific user is logged in). Useful for desktops; rare on a VPS.

After dropping or editing a file, run systemctl daemon-reload so systemd re-reads its on-disk state. Forgetting this is the most common "why isn't my change taking effect" cause.

The minimal service unit

Drop this at /etc/systemd/system/myapp.service:

[Unit]
Description=My app
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/myapp --config /etc/myapp.yaml
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target

What each line does:

  • After=network-online.target + Wants=... — start after the network is up. Use this for anything that binds to a port or makes outbound connections.
  • Type=simple — your binary runs in the foreground and stays running. The default and the right choice for ~90% of services. If your app daemonizes/forks, use Type=forking instead.
  • User= / Group= — run as a non-root account. Create the account first with useradd -r -s /usr/sbin/nologin myapp.
  • ExecStart= — absolute path required. Systemd doesn't search $PATH.
  • Restart=on-failure + RestartSec=5s — auto-restart if the process exits non-zero, with a 5-second pause to avoid hammering on persistent failures.
  • WantedBy=multi-user.target — start at boot once systemctl enable is run.

Enable and start:

systemctl daemon-reload
systemctl enable --now myapp
systemctl status myapp

Reading status output

The status command shows what's going on at a glance:

● myapp.service - My app
     Loaded: loaded (/etc/systemd/system/myapp.service; enabled)
     Active: active (running) since Tue 2026-06-22 14:30:01 UTC; 5min ago
   Main PID: 12345 (myapp)
      Tasks: 8
     Memory: 42.0M
        CPU: 1.234s

Key signals:

  • Loaded: systemd found and parsed the file. If "not-found", check the filename and run daemon-reload.
  • Active: running. "failed" means it crashed; "inactive" means it stopped (could be normal, could be a crash if you expected it running).
  • Main PID — the actual process. Use this in ps, strace, lsof for further debugging.

Logs

# Live tail (Ctrl-C to exit)
journalctl -u myapp -f

# Last 100 lines, newest first
journalctl -u myapp -n 100 --no-pager

# Since boot
journalctl -u myapp -b

# Since timestamp
journalctl -u myapp --since "2026-06-22 14:00"

If your service writes to stderr/stdout, journald captures it automatically. You don't need to wire up any logging library.

Hardening (worth adding even for tiny services)

Most of these are one-line additions under [Service] that reduce blast radius if the service is compromised:

NoNewPrivileges=true        # service can't escalate via setuid binaries
PrivateTmp=true             # gets its own /tmp, /var/tmp; can't see other processes' tmpfiles
ProtectSystem=strict        # /usr, /boot, /etc read-only to the service
ProtectHome=true            # /home, /root, /run/user not visible
ReadWritePaths=/var/lib/myapp /var/log/myapp   # carve out the paths it DOES need to write

These get more aggressive (and more potentially-breaking) up the stack — start with the four above, add more once the service is running well.

Editing a package-shipped unit safely

Don't modify /lib/systemd/system/*.service — package upgrades will overwrite you. Use override drops:

# Opens an editor at /etc/systemd/system/postgresql.service.d/override.conf
systemctl edit postgresql

# After editing, systemd auto-reloads
systemctl restart postgresql

The override file gets MERGED with the package's original. To replace a setting entirely (e.g. clear an inherited ExecStart before setting your own), assign the empty string first:

[Service]
ExecStart=
ExecStart=/usr/bin/my-replacement-command

Common failure modes

  • "status=203/EXEC"ExecStart path doesn't exist or isn't executable. Check ls -l on the binary.
  • "status=1/FAILURE" — the process exited with code 1. Check the journal for what it said.
  • Service starts then immediately stops, no error in journalType= is wrong. If your binary forks/daemonizes, change to Type=forking; if it stays foreground, ensure it's actually staying foreground (some apps need a --foreground flag).
  • Permissions issues at startup — the user you specified can't read the config or write to the working dir. journalctl will tell you which path.

Also Read

« « Back

Powered by WHMCompleteSolution