systemd has hundreds of directives, and man systemd.directives lists every one of them with the page that documents it. That index is complete but not selective. This reference covers the directives that appear in real service files, grouped by section, with the defaults and the traps that the man pages mention only in passing.
If a unit is already failing, start with our guide to debugging systemd unit failures; for a worked end-to-end service, see running JupyterLab as a systemd service. To get a first draft quickly, the systemd unit generator writes the file and this page explains each line.
Where Unit Files Live and How Overrides Work
| Path | Owner | Precedence |
|---|---|---|
/etc/systemd/system/ | Administrator | Highest: wins over everything below |
/run/systemd/system/ | Runtime, generated units | Lost at reboot |
/usr/local/lib/systemd/system/ | Locally installed software | Above package units |
/usr/lib/systemd/system/ | Distribution packages | Lowest; never edit here (Debian also uses /lib before merged /usr) |
~/.config/systemd/user/ | A user's own units | For systemctl --user |
# The unit exactly as systemd sees it, including drop-ins
$ systemctl cat nginx.service
# Create a drop-in override (opens an editor, then reloads)
$ sudo systemctl edit nginx.service
# -> /etc/systemd/system/nginx.service.d/override.conf
# Replace a list-type directive: clear it first, then set it
[Service]
ExecStart=
ExecStart=/usr/sbin/nginx -g 'daemon off;' -c /etc/nginx/alt.conf
# After editing any unit file by hand
$ sudo systemctl daemon-reload
$ systemd-analyze verify /etc/systemd/system/myapp.service
$ systemctl show myapp.service -p Restart -p User -p ExecStart
Empty assignment resets β most directives that accept a list (ExecStart=, Environment=, After=, conditions) are additive across drop-ins. An empty assignment such as ExecStart= clears the list; without it, a second ExecStart on a non-oneshot service is an error.
Drop-ins over copies β systemctl edit --full copies the whole unit into /etc, which then hides future package updates to it. Prefer a drop-in containing only what you change.
Find the real search path β systemd-analyze unit-paths prints every directory systemd reads on this machine, in order.
[Unit]: Description, Dependencies and Ordering
The most misunderstood fact in systemd: dependencies and ordering are separate. Requires= says "start that too"; it does not say "start it first". Without After=, both start in parallel.
| Directive | What it means |
|---|---|
Description= | Short human-readable name shown in status output and logs |
Documentation= | Space-separated URIs: http(s)://, man:, file: |
Wants= | Weak dependency: start the other unit too, but do not care if it fails |
Requires= | Strong: if the other unit fails to start (with After=), this one is not started; stopping it stops this one |
Requisite= | The other unit must already be active, or this start fails immediately |
BindsTo= | Like Requires=, and also stop this unit whenever the other one stops for any reason |
PartOf= | Stop and restart of the other unit propagate to this one (one direction only) |
Conflicts= | Starting this unit stops the other, and vice versa |
After= / Before= | Ordering only: wait for the other unit to finish starting |
OnFailure= | Units to start when this one enters the failed state (alerts, cleanup) |
StartLimitIntervalSec= / StartLimitBurst= | Rate limit for starts; defaults are 5 starts per 10s |
DefaultDependencies=no | Drop the implicit ordering against basic.target and shutdown (early-boot units only) |
[Unit]
Description=Order queue worker
Documentation=https://wiki.example.internal/worker man:worker(8)
Wants=network-online.target
After=network-online.target postgresql.service
Requires=postgresql.service
OnFailure=notify-failure@%n.service
StartLimitIntervalSec=300
StartLimitBurst=5
# Run only where it makes sense; a failed Condition skips, it does not fail
ConditionPathExists=/etc/worker/worker.toml
ConditionVirtualization=!container
AssertPathIsDirectory=/srv/worker
network-online needs both β After=network-online.target alone does nothing unless something pulls the target in. Pair it with Wants=network-online.target.
Condition vs Assert β a false Condition* marks the start as skipped and is logged quietly. A false Assert* makes the start fail. Prefix a value with ! to negate it, and with | to make it one of several OR-ed triggering conditions.
StartLimit* moved β older guides put StartLimitInterval= in [Service]. Since systemd 230 the directives are StartLimitIntervalSec= and StartLimitBurst= in [Unit]. Reset a tripped limit with systemctl reset-failed unit.
[Service]: Type, Exec Lines, Restart and Timeouts
| Type= | systemd considers the service started when... | Use for |
|---|---|---|
simple | the process has been forked (default when ExecStart is set) | Foreground daemons; startup errors are not detected |
exec | the binary has been successfully executed (systemd 240+) | Like simple, but a missing binary fails the start |
forking | the initial process exits; set PIDFile= so the main PID is known | Traditional self-daemonising servers |
oneshot | the process has exited; pair with RemainAfterExit=yes | Setup scripts, one-off tasks, timer jobs |
notify | the daemon sends READY=1 via sd_notify | Daemons with systemd support (nginx is not one; postgres is) |
notify-reload | as notify; reload sends SIGHUP and waits for RELOADING=1 (systemd 253+) | Newer notify-aware daemons |
dbus | the name in BusName= appears on the D-Bus | D-Bus services |
idle | like simple, but delayed until other jobs are dispatched | Cosmetic: avoids console output clashing at boot |
| Directive | Notes |
|---|---|
ExecStart= | The main command. One per service unless Type=oneshot. No shell: pipes, redirects and && are not interpreted |
ExecStartPre= / ExecStartPost= | Run before / after ExecStart; a failure aborts the start unless prefixed with - |
ExecCondition= | Exit 1-254 skips the unit without failing it; 255 fails it (systemd 243+) |
ExecReload= | Command for systemctl reload; often /bin/kill -HUP $MAINPID |
ExecStop= / ExecStopPost= | Graceful stop command; StopPost also runs after a failed start |
Restart= | no (default), on-success, on-failure, on-abnormal, on-watchdog, on-abort, always |
RestartSec= | Wait before restarting; default 100ms, which is why crash loops hit StartLimitBurst so fast |
TimeoutStartSec= / TimeoutStopSec= | Default 90s each; TimeoutSec= sets both; infinity disables |
SuccessExitStatus= | Extra exit codes or signals that count as clean, e.g. 143 for JVMs killed by SIGTERM |
KillMode= | control-group (default, kill everything), mixed (SIGTERM main, SIGKILL rest), process (main only) |
WatchdogSec= | Daemon must send WATCHDOG=1 within this interval or it is treated as hung |
# Exec line prefixes
ExecStartPre=-/usr/bin/rm -f /run/myapp/stale.sock # "-": ignore a non-zero exit
ExecStartPre=+/usr/local/sbin/myapp-prepare # "+": run as root, no sandboxing
ExecStart=@/usr/bin/python3 myapp-worker /srv/app/w.py # "@": second word becomes argv[0]
# Variables: ${VAR} stays one argument, $VAR is split on whitespace
Environment="OPTS=--threads 4 --verbose" "PORT=8080"
ExecStart=/srv/app/bin/server $OPTS --port ${PORT}
# Need shell features? Ask for a shell explicitly
ExecStart=/bin/sh -c 'exec /srv/app/bin/server 2>&1 | logger -t server'
[Service]: Users, Environment, Directories and Limits
| Directive | Effect |
|---|---|
User= / Group= | Run as this account; default is root |
DynamicUser=yes | Allocate a transient UID at start; implies ProtectSystem=strict, ProtectHome=read-only, PrivateTmp |
SupplementaryGroups= | Additional groups, e.g. ssl-cert on Debian to read private keys |
WorkingDirectory= | cwd for Exec lines; ~ means the User's home; prefix - to ignore a missing dir |
Environment= | KEY=value pairs; quote each pair that contains spaces |
EnvironmentFile= | Read KEY=value lines from a file; -/path ignores a missing file |
StandardOutput= / StandardError= | journal (default), null, file:/path, append:/path, truncate:/path, inherit |
SyslogIdentifier= | Tag used in the journal instead of the binary name |
UMask= | File creation mask; default 0022 |
StateDirectory= | Creates /var/lib/NAME owned by User=, exported as $STATE_DIRECTORY |
RuntimeDirectory= | Creates /run/NAME at start and removes it at stop |
CacheDirectory= / LogsDirectory= | /var/cache/NAME and /var/log/NAME, same ownership rules |
LimitNOFILE= | Open-file limit; /etc/security/limits.conf does not apply to services |
MemoryMax= / MemoryHigh= | Hard cap (OOM kill) and soft throttle threshold for the unit's cgroup |
CPUQuota= | CPU time cap, e.g. 50% (half a core) or 200% (two cores) |
TasksMax= | Maximum processes and threads in the unit |
Nice= / IOWeight= / CPUWeight= | Scheduling priority and relative shares under contention |
Distro conventions for env files β Debian and Ubuntu packages read /etc/default/NAME; RHEL and Fedora use /etc/sysconfig/NAME. Check systemctl cat to see which the packaged unit expects.
limits.conf is ignored β PAM limits apply to login sessions, not to services started by systemd. Raise limits with LimitNOFILE= in the unit or a drop-in, then check with cat /proc/$(pidof -s myapp)/limits.
Resource control and cgroup v2 β MemoryMax=, MemoryHigh= and the Weight= settings behave as documented on the unified cgroup v2 hierarchy, the default on RHEL 9, Debian 11 and later, and Ubuntu 21.10 and later. RHEL 8 boots with cgroup v1 by default, where some of them are mapped to older controls or ignored.
Do not chown in ExecStartPre β the *Directory= directives create and own their paths for you, and they remain writable even under ProtectSystem=strict.
Sandboxing Directives
These cost nothing at runtime and close off most of the filesystem and kernel from a compromised service. Add them one at a time and restart after each; when something breaks, the journal usually shows Read-only file system or Permission denied pointing to the cause. See our server hardening guide for where this fits in a wider baseline.
| Directive | Effect | Watch out for |
|---|---|---|
NoNewPrivileges=yes | setuid binaries and file capabilities cannot raise privileges | Services that call sudo or ping via setuid |
ProtectSystem= | yes: /usr and /boot read-only; full: also /etc; strict: entire FS read-only | strict needs ReadWritePaths= for anything not covered by *Directory= |
ProtectHome= | yes: /home, /root, /run/user inaccessible; read-only; tmpfs | Apps whose data lives under a home directory |
PrivateTmp=yes | Private /tmp and /var/tmp | Sharing files via /tmp with other services stops working |
PrivateDevices=yes | Minimal /dev without physical devices | GPU or serial-port access |
PrivateNetwork=yes | Only a loopback interface | Anything that needs the network |
ProtectKernelTunables= / ProtectKernelModules= | /proc/sys and /sys read-only; no module loading | Rarely a problem for applications |
ProtectControlGroups=yes | cgroup filesystem read-only | Container runtimes |
ReadWritePaths= / ReadOnlyPaths= / InaccessiblePaths= | Punch exceptions in either direction | Paths must exist unless prefixed with - |
RestrictAddressFamilies= | e.g. AF_UNIX AF_INET AF_INET6 | Netlink users need AF_NETLINK |
CapabilityBoundingSet= / AmbientCapabilities= | Limit or grant capabilities, e.g. CAP_NET_BIND_SERVICE for port 443 as a non-root user | An empty bounding set removes everything |
SystemCallFilter=@system-service | Allow-list of syscalls typical services need | Pair with SystemCallArchitectures=native |
MemoryDenyWriteExecute=yes | No writable and executable memory | Breaks JITs: Java, Node.js, some Python extensions |
# Score a unit's exposure (0 = locked down, 10 = fully exposed)
$ systemd-analyze security myapp.service | tail -1
β Overall exposure level for myapp.service: 2.1 OK :-)
# Try a directive without editing the unit
$ sudo systemd-run --pipe --wait -p ProtectSystem=strict -p ProtectHome=yes \
-p PrivateTmp=yes /usr/bin/touch /etc/test
/usr/bin/touch: cannot touch '/etc/test': Read-only file system
[Install], Templates and Specifiers
The [Install] section is read only by systemctl enable and disable, never at runtime. WantedBy=multi-user.target means "enable creates a symlink in multi-user.target.wants/", which is how the service starts at boot.
| Directive / specifier | Meaning |
|---|---|
WantedBy= / RequiredBy= | Target or unit that pulls this one in when enabled |
Alias= | Extra names created as symlinks on enable |
Also= | Units enabled and disabled together with this one, e.g. a matching .socket |
DefaultInstance= | Instance used when a template is enabled without one |
%n / %N | Full unit name / the same without the type suffix |
%p | Prefix: the part before @ in a template |
%i / %I | Instance name, escaped / unescaped |
%H / %u / %h | Hostname / user name / user's home directory |
%t / %S / %C / %L / %E | Runtime, state, cache, logs and configuration roots (/run, /var/lib, /var/cache, /var/log, /etc) |
%% | A literal percent sign (needed in date formats) |
# /etc/systemd/system/[email protected] β one unit file, many instances
[Unit]
Description=Queue worker %i
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=worker
EnvironmentFile=/etc/worker/%i.env
ExecStart=/srv/worker/bin/worker --queue %i
Restart=on-failure
RestartSec=5s
StateDirectory=worker/%i
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
$ sudo systemctl enable --now [email protected] [email protected]
$ systemctl status 'worker@*'
| Distribution | systemd | Missing from the tables above |
|---|---|---|
| RHEL 8 / Rocky 8 / Alma 8 | 239 | Type=exec, ExecCondition=, notify-reload, OnSuccess= |
| Ubuntu 22.04 | 249 | notify-reload |
| Debian 12, RHEL 9 | 252 | notify-reload |
| Ubuntu 24.04 | 255 | Nothing listed here |
Check before you rely on a directive β systemctl --version shows the version, and systemd-analyze verify warns about directives this version does not know (they are otherwise ignored with only a log message).
Timers β scheduled units pair a .service with a .timer carrying OnCalendar= and Persistent=; the cron generator helps translate an existing crontab schedule first.
Frequently Asked Questions
What is the difference between Requires= and After= in systemd?
Requires= is a dependency: starting this unit also starts the other, and stopping the other stops this one. After= is ordering only: if both are being started, this one waits until the other has finished starting. They are independent, so for a real start-after dependency you normally set both.
Which Type= should I use for my service?
Use exec (or simple on systemd older than 240) for a program that stays in the foreground, notify if the daemon supports sd_notify, forking with PIDFile= for a traditional daemon that backgrounds itself, and oneshot with RemainAfterExit=yes for scripts that run and exit.
Why does my ExecStart pipe or redirect not work?
systemd does not run Exec lines through a shell, so |, >, && and globbing are passed to the program as literal arguments. Wrap the command in /bin/sh -c '...' when you need shell features, or use StandardOutput=append:/path for simple redirection.
How do I override a packaged unit file without editing it?
Run systemctl edit name.service, which creates a drop-in at /etc/systemd/system/name.service.d/override.conf and reloads systemd. Put only the changed directives there. To replace a list directive such as ExecStart=, first add an empty ExecStart= line, then the new value.
Why is my service stuck with start request repeated too quickly?
The unit hit its start rate limit, by default 5 starts within 10 seconds. With RestartSec at its 100ms default, a crashing service reaches it almost instantly. Fix the crash, raise RestartSec, tune StartLimitIntervalSec= and StartLimitBurst= in [Unit], and clear the state with systemctl reset-failed.