Systemd Service Unit File Tutorial

by Liam Foster
Systemd Service Unit File Tutorial

A systemd service unit file is a text configuration that tells Linux how to start, stop, and manage a background process (daemon). It replaces the old init.d scripts with declarative syntax: you describe the desired state, systemd enforces it.

Unit files live in /etc/systemd/system/ (custom services) or /usr/lib/systemd/system/ (package-provided). Systemd reads them at boot and on-demand, parsing sections like [Unit], [Service], and [Install].

Core Structure

Every service unit file has three main sections:

[Unit] — metadata and dependencies

  • Description: Human-readable name (shows in logs and systemctl status).
  • After: Ordering hint. After=network-online.target means "start after network is ready," not "wait for it." Use Requires= or Wants= if you need hard dependency.
  • Requires: Service fails if listed unit fails. Rarely needed; Wants= is safer.
  • Wants=: Service continues even if listed unit fails. Preferred for optional deps.

[Service] — the process itself

  • Type: How systemd tracks the process. simple (default) means the main process runs in foreground. forking means it daemonizes (forks to background). notify means the process signals systemd when ready.
  • ExecStart: The command to run. Must be an absolute path; no shell expansion.
  • ExecStop: Optional command to gracefully stop the service.
  • User and Group: POSIX user/group to run as. Systemd drops privileges; never run as root unless necessary.
  • Restart: Restart policy. on-failure restarts only on non-zero exit. always restarts even on clean exit (useful for long-running daemons). no (default) never restarts.
  • RestartSec: Seconds to wait before restarting. Default 100ms.

[Install] — boot-time behavior

  • WantedBy=: Which target "wants" this service. multi-user.target is the standard boot target. Creates a symlink in /etc/systemd/system/multi-user.target.wants/ when you run systemctl enable.

Minimal Example

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

[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/python3 /opt/myapp/main.py
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

Save this as /etc/systemd/system/myapp.service. Run systemctl daemon-reload (tells systemd to re-parse unit files), then systemctl enable myapp.service (adds boot symlink), then systemctl start myapp.service.

Critical Directives and Gotchas

ExecStart must be absolute, no pipes or redirects. If your app needs shell syntax, wrap it:

ExecStart=/bin/bash -c 'exec /usr/bin/myapp > /var/log/myapp.log 2>&1'

The exec replaces the bash process, so systemd tracks the right PID.

Type=forking is a trap. Old daemons fork themselves to background. Systemd loses track of the real process unless you also set PIDFile=/run/myapp.pid. Avoid it; rewrite the app to run in foreground (Type=simple) or use Type=notify with systemd library bindings. If you must fork, test with systemctl status — it should show the main PID, not a dead parent.

StandardOutput and StandardError default to journal. Logs go to systemd's journal, visible via journalctl -u myapp.service. This is intentional; don't redirect to files unless you have a reason (log rotation, archival, etc.). If you do, set up logrotate or use journal and query with journalctl.

Restart=always will restart even on clean shutdown. If your app exits 0 (success), always still restarts it. Use on-failure unless you want a watchdog loop. Combine with RestartSec=10 to avoid hammering.

PrivateTmp=yes isolates /tmp. Each service gets its own /tmp mount. Useful for security; breaks apps that expect shared temp. Check app docs.

Environment variables are not inherited from your shell. Set them in the unit file:

Environment="LOG_LEVEL=debug"
EnvironmentFile=/etc/myapp/env

EnvironmentFile reads key=value pairs (one per line, no quotes needed).

Dependency Model

Systemd uses a graph of dependencies. After= is an ordering constraint, not a hard requirement. If you need the service to fail if a dependency fails, use Requires=. If you want it to start after a target but tolerate failure, use Wants=.

Example: A web app that needs the database.

[Unit]
Description=Web App
After=postgresql.service
Requires=postgresql.service

If PostgreSQL stops, systemd stops the web app. If PostgreSQL never starts, the web app never starts.

For optional deps (nice-to-have):

After=redis.service
Wants=redis.service

The app starts regardless, but systemd orders it after Redis if Redis is enabled.

Testing and Debugging

  1. Syntax check: systemd-analyze verify /etc/systemd/system/myapp.service
  2. Dry-run: systemctl start --no-block myapp.service starts asynchronously; check status with systemctl status myapp.service.
  3. Logs: journalctl -u myapp.service -n 50 shows last 50 lines. Add -f to follow in real-time.
  4. Reload after edits: systemctl daemon-reload re-parses all unit files.
  5. Check what's enabled: systemctl list-unit-files --state=enabled shows boot-time services.

When This Breaks

Service won't start: Check systemctl status myapp.service for the error. Common causes: wrong path in ExecStart, missing user/group, permission denied, syntax error in unit file. Use systemd-analyze verify first.

Service restarts in a loop: Restart=always with a crashing app creates a restart loop. Check logs with journalctl -u myapp.service --no-pager | tail -20. Fix the app, then restart the service.

Dependencies not working: After= doesn't guarantee the dep is running; it only orders the start. Use Requires= or Wants= if you need the dep to actually be up.

Permission denied on log files: If User=appuser but logs are in /var/log/ (owned by root), the app can't write. Either create the log dir with correct ownership (mkdir -p /var/log/myapp && chown appuser:appgroup /var/log/myapp) or use the journal.

Trade-offs

Systemd unit files are declarative and portable across distributions. The cost: less flexibility than shell scripts. If your startup logic is complex (conditional checks, loops), consider a wrapper script and call it from ExecStart.

Using the journal (StandardOutput=journal) centralizes logs and integrates with systemd tools, but requires learning journalctl. File-based logs are simpler for traditional log aggregation (Splunk, ELK) but need rotation and cleanup.

Privacy and security directives (PrivateTmp=, NoNewPrivileges=, ProtectSystem=) improve isolation but may break legacy apps. For hardening ideas beyond the unit file itself — such as locking down user accounts running these services — the writeup on digitalwarga.id covers two-factor authentication as an additional layer worth considering. Test in a non-prod environment first.

One-line takeaway

Write a systemd service unit file to declare what your daemon is, how to run it, and when to restart it—then let systemd enforce it reliably across reboots and failures.