systemd Service Unit File Tutorial

by Liam Foster
systemd Service Unit File Tutorial

A systemd service unit file is a declarative config that tells systemd how to start, stop, and manage a long-running process. It replaces shell scripts and init.d chaos with a standardized interface. Write it once, systemd handles restart logic, logging, dependencies, and resource limits.

The Unit File Structure

Every service unit file lives in /etc/systemd/system/ (or /usr/lib/systemd/system/ for package-provided units) and follows INI-style syntax:

[Unit]
Description=My Application
After=network.target

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

[Install]
WantedBy=multi-user.target

Three sections: [Unit] (metadata and ordering), [Service] (process behavior), [Install] (enable/disable targets). Each directive is a key=value pair. No quotes needed unless the value contains spaces.

Core Directives Explained

[Unit] section:

Description is free text, shown in systemctl status and logs. Make it searchable.

After=network.target ensures the service starts after networking is ready. This is a soft dependency—systemd won't fail if the target doesn't exist. Use Requires= or BindsTo= for hard dependencies that will block the service if the dependency fails.

Before= runs this service before another target or unit.

[Service] section:

Type defines how systemd interprets the process:

  • simple (default): the main process stays running. systemd assumes the service is ready immediately after fork. Use this for most daemons.
  • forking: the process daemonizes itself and exits the parent. systemd waits for the parent to exit, then considers the service started. Use for legacy daemons that double-fork.
  • oneshot: the process runs once and exits. Useful for setup tasks. Set RemainAfterExit=yes if you want systemd to consider it "active" even after exit.
  • notify: the service sends a readiness signal via sd_notify(). systemd waits for that signal before marking it started. Prevents "started but not ready" races.

User and Group drop privileges. The process runs as this user; systemd handles the setuid syscall. Always use a dedicated unprivileged user in production.

WorkingDirectory sets the cwd before exec. Relative paths in your app config will be relative to this.

ExecStart is the command to run. Absolute path required. Arguments are space-separated; use quotes for args with spaces. Only one ExecStart per unit (but you can chain multiple ExecStartPre= and ExecStartPost= hooks).

Restart controls restart behavior:

  • no (default): do not restart.
  • on-failure: restart only if the process exits with a non-zero code or is killed by a signal.
  • on-success: restart only on clean exit (code 0).
  • always: restart regardless.

RestartSec=10 waits 10 seconds between restart attempts. Prevents restart loops from hammering resources.

StandardOutput and StandardError direct logs to journal (default), syslog, or a file. Use journal for most cases; systemd's journal is queryable and rotated automatically.

[Install] section:

WantedBy=multi-user.target means "enable this service as a dependency of multi-user.target". When you run systemctl enable myservice, systemd creates a symlink in /etc/systemd/system/multi-user.target.wants/. This is a soft dependency—the target will start even if this service fails.

Use RequiredBy= for a hard dependency (the target will not reach "active" if this service fails).

Lifecycle and Signals

When you run systemctl stop myservice, systemd sends SIGTERM to the process. It waits TimeoutStopSec= (default 90 seconds) for a clean exit. If the process doesn't exit, systemd sends SIGKILL.

Set TimeoutStopSec=30 if your app shuts down quickly, to avoid the default 90-second wait.

ExecStop= runs a custom stop command instead of relying on signals. Useful if your process needs special cleanup.

KillMode= controls which processes are killed when the service stops:

  • control-group (default): kill all processes in the cgroup, including children.
  • process: kill only the main process; children survive.
  • mixed: send SIGTERM to the main process, SIGKILL to children.
  • none: don't kill anything (rare).

Resource Limits and Security

Systemd can enforce resource constraints without writing cgroup config directly:

MemoryLimit=512M
CPUQuota=50%
LimitNOFILE=65536

MemoryLimit caps RSS. CPUQuota restricts CPU time (50% = half a core). LimitNOFILE sets the max file descriptors. These are safer than ulimit because they're enforced by the kernel, not the shell.

PrivateTmp=yes gives the service a private /tmp namespace. NoNewPrivileges=yes prevents the process from gaining new capabilities via setuid binaries. These are cheap security wins.

Debugging and Validation

Before enabling a unit, validate it:

systemd-analyze verify /etc/systemd/system/myservice.service

This catches syntax errors and common mistakes. After editing, reload the daemon:

sudo systemctl daemon-reload

Then start and check status:

sudo systemctl start myservice
sudo systemctl status myservice
journalctl -u myservice -n 50

journalctl -u myservice -f tails logs in real time. Use -p err to filter by severity.

When This Breaks

Service won't start: Check journalctl -u myservice -n 20 for the error. Common causes: wrong path in ExecStart, missing User account, permission denied on WorkingDirectory.

Service starts but exits immediately: If Type=simple, systemd assumes the process is ready immediately. If your app needs time to initialize, use Type=notify or add a health check via ExecStartPost=.

Restart loop: Set RestartSec= to a reasonable value (10+ seconds) to avoid hammering. Check logs for the root cause—restarting won't fix a config error.

Port already in use: If your service binds to a port, ensure the old process is dead before restarting. Use KillMode=control-group to kill child processes. If a port is stuck in TIME_WAIT, increase RestartSec= to outlast the TCP timeout.

Dependency ordering: After= and Before= don't guarantee a service starts; they only set order. Use Requires= if you need a hard dependency, but be aware it can prevent the target from reaching "active" if the dependency fails.

Trade-offs

Systemd unit files are declarative and portable across distributions, but they tie your service to systemd. Non-Linux systems (BSD, macOS) use launchd or other supervisors. If you need cross-platform, wrap your service in a shell script that systemd calls, or use a language-level supervisor (e.g., Python's supervisor). If you're running this in a self-hosted environment, cara setup self-hosted development environment covers the broader infrastructure context worth reviewing alongside your unit file setup.

Type=notify is more robust than Type=simple because it waits for explicit readiness, but it requires your app to call sd_notify(). If your app doesn't support it, you're stuck with Type=simple and guessing at startup time.

Restart policies are blunt—they restart on any exit. If you need fine-grained control (restart only on specific errors), use a wrapper script that exits with different codes and set RestartForceExitStatus= to handle them.

One-line Takeaway

Write a systemd service unit file to declare process lifecycle, dependencies, and resource limits in a standard, auditable format—then let systemd handle the rest.