systemd Cheatsheet
Environment and Users
Use this systemd reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Setting Environment Variables
A unit starts with an almost empty environment. Nothing from your login shell reaches it.
[Service] Environment=NODE_ENV=production Environment="LOG_FORMAT=json" "APP_NAME=my app" Environment=PATH=/usr/local/bin:/usr/bin:/bin EnvironmentFile=/etc/myapp/env EnvironmentFile=-/etc/myapp/env.local PassEnvironment=TZ LANG UnsetEnvironment=LD_PRELOAD
| Directive | Behaviour |
|---|---|
Environment= | Inline KEY=VALUE pairs, space separated, quote values containing spaces |
EnvironmentFile= | Read KEY=VALUE lines from a file. Missing file is a hard failure |
EnvironmentFile=- | Same, but a missing file is silently ignored |
PassEnvironment= | Copy named variables from the manager's environment |
UnsetEnvironment= | Remove a variable that something else set |
Environment files are not shell scripts. There is no export, no command substitution, no variable interpolation between lines, and # starts a comment. Values may be single or double quoted. Repeat EnvironmentFile= to load several, later files winning.
Both directives accumulate, so a drop-in that adds Environment= keeps the old values too. Reset with a bare assignment first:
[Service] Environment= Environment=NODE_ENV=staging
Variables Inside ExecStart=
ExecStart= is not run by a shell, so expansion is systemd's own and deliberately limited.
[Service] Environment=PORT=8080 FLAGS="--verbose --json" ExecStart=/usr/local/bin/myapp --port ${PORT} # single argument, no splitting ExecStart=/usr/local/bin/myapp $FLAGS # split into two arguments
${VAR}substitutes as one argument, whitespace and all.$VARsubstitutes and then splits on whitespace.$MAINPIDis available inExecReload=,ExecStop=andExecStopPost=.$SERVICE_RESULT,$EXIT_CODEand$EXIT_STATUSare set inExecStopPost=.- No globbing, no
~, no&&, no pipes, no redirection. For any of those, be explicit:
ExecStart=/bin/sh -c 'exec /usr/local/bin/myapp >> /var/log/myapp.log 2>&1'Prefix characters on an Exec*= line, before the path:
| Prefix | Effect |
|---|---|
- | Ignore a non-zero exit status |
@ | Pass the next token as argv[0] |
: | Skip environment variable substitution |
+ | Run with full privileges, no sandboxing, no User= |
! | Apply User=/Group= but skip namespace sandboxing |
!! | Like !, but only on kernels without ambient capabilities |
Running as a Non-Root User
[Service]
User=myapp
Group=myapp
SupplementaryGroups=redis docker
UMask=0027
WorkingDirectory=/srv/myappIf the user does not exist the unit fails with 217/USER. Create a system account first, or let systemd do it:
[Service] DynamicUser=yes StateDirectory=myapp
DynamicUser=yes allocates a transient UID/GID at start and releases it at stop. Nothing appears in /etc/passwd. It implies ProtectSystem=strict, ProtectHome=read-only, PrivateTmp=yes, NoNewPrivileges=yes and RestrictSUIDSGID=yes, so the unit can barely write anywhere. It is excellent for stateless services, and you must pair it with the managed directories below for anything that keeps data, because a recycled UID would otherwise leave unreadable files behind.
User= in a user unit is not allowed. A --user manager already runs as you.
Managed Directories
Let systemd create, own and clean up paths rather than shipping mkdir in an ExecStartPre=. Each directive is relative to a fixed root and gets the unit's user and group with mode 0755.
| Directive | Path | Lifetime |
|---|---|---|
RuntimeDirectory=myapp | /run/myapp | Removed when the unit stops |
StateDirectory=myapp | /var/lib/myapp | Persistent |
CacheDirectory=myapp | /var/cache/myapp | Persistent, safe to wipe |
LogsDirectory=myapp | /var/log/myapp | Persistent |
ConfigurationDirectory=myapp | /etc/myapp | Persistent |
[Service]
StateDirectory=myapp
StateDirectoryMode=0750
RuntimeDirectory=myapp
RuntimeDirectoryPreserve=restart
CacheDirectory=myappThe absolute paths are also exported as $RUNTIME_DIRECTORY, $STATE_DIRECTORY, $CACHE_DIRECTORY, $LOGS_DIRECTORY and $CONFIGURATION_DIRECTORY, so your code never has to hardcode them. In a --user unit the roots become $XDG_RUNTIME_DIR, ~/.local/state, ~/.cache, ~/.local/state/log and ~/.config.
Secrets, Not Environment Variables
Environment variables leak. systemctl show prints them, /proc/<pid>/environ exposes them, and they are inherited by children. Use credentials instead (systemd 247+).
[Service]
LoadCredential=db-password:/etc/myapp/db-password
LoadCredentialEncrypted=api-key:/etc/myapp/api-key.cred
SetCredential=greeting:hello
ExecStart=/usr/local/bin/myapp --password-file ${CREDENTIALS_DIRECTORY}/db-passwordCredentials land in $CREDENTIALS_DIRECTORY, a ramfs mount readable only by the service user, mode 0400, never swapped and gone when the unit stops. Encrypt one at rest with:
systemd-creds encrypt --name=api-key plain.txt /etc/myapp/api-key.cred systemd-creds list
If you must use an EnvironmentFile=, at least chown root:myapp it and chmod 0640.
Seeing the Real Environment
systemctl show -p Environment myapp.service systemctl show -p EnvironmentFiles myapp.service systemctl show -p ExecStart myapp.service systemd-run --pty --uid=myapp /usr/bin/env # what the process really sees systemd-run -p Environment=FOO=bar --pty /usr/bin/env sudo tr '\0' '\n' < /proc/$(systemctl show -p MainPID --value myapp)/environ
systemd-run is the fastest way to reproduce a unit's environment without editing anything. Add -p for any [Service] property, and --pty to see output interactively.