Systemd Service Unit File Tutorial

by Liam Foster
Systemd Service Unit File Tutorial

A systemd service unit file is a declarative text configuration that tells systemd how to start, stop, and manage a long-running application. Instead of shell scripts or cron jobs, you describe the desired state—dependencies, restart policy, user permissions, environment variables—and systemd handles the execution and supervision.

This is the foundation of modern Linux operations. Get the unit file right and your application restarts cleanly on failure, starts in the correct order, and integrates with the rest of your system. Get it wrong and you'll spend hours debugging why a service won't start after a reboot.

The Basic Structure

A systemd service unit file lives in /etc/systemd/system/ (or /usr/lib/systemd/system/ for packages) and follows INI-style syntax:

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

[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/server
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Three sections: [Unit] (metadata and ordering), [Service] (execution parameters), [Install] (how to enable the service). Each section controls a different layer of behavior.

Unit Section: Dependencies and Ordering

The [Unit] section declares what your service needs and when it should start relative to other units.

Description is human-readable; systemd logs it. Use it to identify the service in systemctl status output.

After specifies ordering: your service will not start until the listed units are active. After=network.target means "wait for networking." This is not a hard dependency—the target can fail and your service will still try to start. If you need the target to actually succeed, use Requires=.

Requires creates a hard dependency. If the required unit fails, your service is stopped. Use this sparingly; it can cascade failures.

Before is the inverse of After: other units wait for yours. Rarely needed in application services.

ConditionPathExists gates startup: the service only starts if the path exists. Useful for optional features or mount-dependent services.

Gotcha: After= does not start the dependency—it only orders it. If you need a service to start postgres before your app, write After=postgresql.service and systemd will wait for it, but postgres must be enabled separately or listed in Wants= (a soft dependency that starts the target but doesn't fail if it can't).

Service Section: How the Application Runs

Type defines the startup contract:

  • simple (default): systemd considers the service started immediately after ExecStart forks. Use this for applications that daemonize themselves or don't fork at all.
  • forking: systemd waits for the main process to exit and tracks the child. Old-style daemons use this; modern apps avoid it.
  • oneshot: the service runs once and exits; systemd considers it active until you stop it. Useful for setup scripts.
  • notify: the application sends a signal to systemd when it's ready. Requires the application to use the systemd notification protocol; most do not.

User and Group drop privileges: the process runs as this user. Always set this for security. If the user doesn't exist, the service fails to start—no graceful fallback.

WorkingDirectory sets the working directory before ExecStart runs. Paths in ExecStart are relative to this unless absolute.

ExecStart is the command to run. Use absolute paths. Environment variables are expanded if you set them in Environment= or EnvironmentFile=. Gotcha: if the command contains spaces or special characters, quote the entire value or use array syntax:

ExecStart=/opt/myapp/bin/server --port 8080

ExecStartPre and ExecStartPost run before and after ExecStart. Useful for setup or cleanup. If ExecStartPre fails, ExecStart is skipped and the service fails.

Restart controls restart behavior on exit:

  • no: never restart (default).
  • always: always restart, regardless of exit code.
  • on-failure: restart only if the exit code is non-zero or the process was killed by a signal.
  • on-success: restart only if the exit code is zero.

RestartSec is the delay (in seconds) before systemd retries. Default is 100ms. Set it to avoid hammering a broken service.

StartLimitBurst and StartLimitIntervalSec prevent restart loops: if the service restarts more than Burst times in IntervalSec seconds, systemd enters a holding pattern and stops restarting. Default is 5 restarts in 10 seconds. Increase this if your app legitimately needs fast restarts during startup.

Environment sets key-value pairs passed to the process:

Environment="LOG_LEVEL=info"
Environment="DATABASE_URL=postgres://localhost/mydb"

EnvironmentFile loads variables from a file (one per line, KEY=VALUE format). Useful for secrets or per-environment config.

StandardOutput and StandardError control where logs go. journal sends them to systemd's journal (queryable with journalctl). append:/path appends to a file. null discards them. Default is journal.

Install Section: Enabling the Service

WantedBy specifies which target(s) should start this service when enabled. WantedBy=multi-user.target means the service starts in multi-user mode (the standard runlevel). When you run systemctl enable myapp.service, systemd creates a symlink in /etc/systemd/system/multi-user.target.wants/ pointing to your unit file.

RequiredBy is the hard-dependency version of WantedBy: if the target is activated, this service must start, and if it fails, the target fails.

Workflow: Create, Test, Deploy

  1. Write the unit file in /etc/systemd/system/myapp.service.
  2. Run systemctl daemon-reload to tell systemd to re-parse the file.
  3. Test with systemctl start myapp.service and check systemctl status myapp.service.
  4. Review logs: journalctl -u myapp.service -n 50 (last 50 lines).
  5. Enable on boot: systemctl enable myapp.service.
  6. Reboot and verify: systemctl is-active myapp.service.

Gotcha: forgetting daemon-reload is the #1 mistake. systemd caches unit files; changes don't take effect until you reload.

When This Breaks

Service fails to start after reboot: Check After= and Requires= dependencies. Verify the binary path exists and is readable by the User=. Run systemctl status myapp.service and journalctl -u myapp.service to see the error.

Service restarts in a loop: Increase StartLimitBurst or check the application logs for a startup error that's not being caught. A common cause is a missing config file or permission issue.

Environment variables not set: Verify they're in the [Service] section, not [Unit]. If using EnvironmentFile=, confirm the file exists and is readable by the User=.

Service stops after a few minutes: Check TimeoutStartSec= (default 90 seconds) and TimeoutStopSec= (default 90 seconds). If your app takes longer to start or stop, increase these. Also check for Restart=on-failure combined with a non-zero exit code.

Trade-offs

Systemd unit files are declarative and integrate tightly with the OS, but they're not portable across non-systemd systems (older Debian, Alpine, BSD). For maximum compatibility, pair them with an application-level process manager (supervisor, runit) or containerize. If your stack runs on WordPress, the comparison on wpcompass.io is worth a look for understanding how managed hosting environments handle service reliability at the infrastructure level.

Restart policies are powerful but blunt: Restart=on-failure will restart even if the app is intentionally exiting with an error code. Use ExecStartPost hooks or application-level health checks for finer control.

One-Line Takeaway

A systemd service unit file is a declarative contract between your application and the OS: write it once, and systemd handles reliable startup, restart, and lifecycle management for the lifetime of the server.