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
DirectiveBehaviour
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.
  • $VAR substitutes and then splits on whitespace.
  • $MAINPID is available in ExecReload=, ExecStop= and ExecStopPost=.
  • $SERVICE_RESULT, $EXIT_CODE and $EXIT_STATUS are set in ExecStopPost=.
  • 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:

PrefixEffect
-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/myapp

If 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.

DirectivePathLifetime
RuntimeDirectory=myapp/run/myappRemoved when the unit stops
StateDirectory=myapp/var/lib/myappPersistent
CacheDirectory=myapp/var/cache/myappPersistent, safe to wipe
LogsDirectory=myapp/var/log/myappPersistent
ConfigurationDirectory=myapp/etc/myappPersistent
[Service]
StateDirectory=myapp
StateDirectoryMode=0750
RuntimeDirectory=myapp
RuntimeDirectoryPreserve=restart
CacheDirectory=myapp

The 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-password

Credentials 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.