Build a Safer systemd Service Environment with systemd.exec
You will finish with a service unit that runs as a dedicated identity, receives configuration without shell expansion, gets managed writable directories, and cannot casually modify the rest of the host filesystem. You will also verify the unit before it can disrupt a running service. The examples match the installed systemd.exec(5) from systemd package version 255.4-1ubuntu8.17, whose manual identifies itself as systemd 255.
The route
Jump straight to the step you need, or tick off Done means at the end.
Allow about 20 minutes. You need a Linux host using systemd, a program that can run as a service, and administrative access for the final installation. The guide uses /usr/local/bin/example-worker as an obvious placeholder. Replace it with a real absolute path before starting anything.
Checkpoint
The examples first use a temporary unit and a syntax check. No service is restarted until the later step that explicitly requires elevated privileges.
1. Start with a small unit
Create a working copy outside the system unit directories. This ordinary shell command changes only a temporary file:
$ umask 077
$ cat > /tmp/example-worker.service <<'UNIT'
[Unit]
Description=Example worker with a restricted execution environment
[Service]
Type=exec
ExecStart=/usr/local/bin/example-worker
User=example-worker
Group=example-worker
UNIT
User= and Group= select the Unix identity for the service process. For a system service, omitting them leaves the default as root, which is a poor starting point for a network-facing or file-processing program. The account must already exist unless you use DynamicUser=yes. This guide uses a named account so ownership and auditing remain easy to understand.
Check the unit before doing anything else:
$ systemd-analyze verify /tmp/example-worker.service
/tmp/example-worker.service:7: Failed to resolve user example-worker: No such process
That diagnostic is expected until the account exists. A real executable may also produce a warning or error if its path is wrong. An empty result is the useful result: the unit passed this syntax and dependency check.
2. Add configuration without accidental expansion
Put non-secret, stable values in Environment=. systemd parses the assignment, but does not perform shell variable expansion inside it, so the dollar sign in $HOME below remains a dollar sign:
[Service]
Environment="WORKER_MODE=production" "WORKER_PATTERN=$HOME/*.job"
Environment="WORKER_LABEL=nightly batch"
EnvironmentFile=-/etc/example-worker/worker.conf
Values in one Environment= line are assignments. Quote the complete assignment when its value contains spaces or an equals sign. Repeating the setting is allowed; a later assignment of the same variable wins. The leading hyphen on EnvironmentFile= makes that file optional. Without it, a missing file is a start-up error.
The file is not a shell script. It contains newline-separated assignments and may use comments, but its quoting rules are systemd's rules. A minimal file could be:
# /etc/example-worker/worker.conf
WORKER_ENDPOINT=https://127.0.0.1:9443/jobs
WORKER_BATCH_SIZE=25
Do not put passwords, private keys or other secrets in either setting. The manual warns that unit environment variables are exposed through D-Bus and inherited by child processes. Use systemd credentials such as LoadCredential= or LoadCredentialEncrypted= for sensitive data.
Common trap: system services do not automatically inherit the environment of the system manager. If a value is deliberately set there, PassEnvironment=NAME can pass it through, but explicit unit configuration is usually easier to audit. UnsetEnvironment=NAME is applied last and can remove a value supplied by any of these sources.
3. Give the service the directories it is meant to write
Do not grant write access to a broad path merely because the program needs one directory. Let systemd create purpose-specific directories and expose their paths through environment variables:
[Service]
RuntimeDirectory=example-worker
StateDirectory=example-worker
CacheDirectory=example-worker
LogsDirectory=example-worker
Environment=WORKER_RUNTIME=%t/example-worker
For a system unit, these settings create directories below /run, /var/lib, /var/cache and /var/log respectively. systemd sets RUNTIME_DIRECTORY, STATE_DIRECTORY, CACHE_DIRECTORY and LOGS_DIRECTORY to the full paths. Prefer those variables in the program or set explicit application variables from them when the program uses its own names.
The runtime directory is removed when the unit stops unless RuntimeDirectoryPreserve= says otherwise. State, cache and log directories remain. This difference matters: a restart should not silently erase state, while a socket or PID directory normally should be recreated. These options also add the mount dependencies needed to reach the paths.
For a dynamic user, these managed directories also avoid the file-ownership hazards of recycled numeric IDs. Do not leave files owned by a dynamic identity in arbitrary shared directories.
4. Add filesystem boundaries one at a time
Now add the protections that are compatible with the program. A useful baseline for a long-running service is:
[Service]
DynamicUser=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/example-worker /var/cache/example-worker /var/log/example-worker
ProtectSystem=strict makes the filesystem hierarchy read-only to the service, apart from the API filesystems. ReadWritePaths= is an allow-list for exceptions. ProtectHome=read-only prevents writes to home directories while keeping them visible. PrivateTmp=yes gives the service private temporary directories. NoNewPrivileges=yes prevents the process and its children from gaining additional privileges through execution.
DynamicUser=yes allocates a transient user and group. It also implies private temporary storage, NoNewPrivileges=, protection against SUID and SGID changes, ProtectSystem=strict and ProtectHome=read-only. Keep the explicit settings while testing: they make the intended boundary visible, and the explicit paths document the assumptions for a future reader.
These are security controls, not decoration. A program that needs to update /etc, inspect user home directories, create device nodes or use a particular kernel interface may fail. Add only the narrow exception you can explain. If the kernel or service manager cannot provide a namespacing feature, some sandbox settings may be unavailable or ineffective, especially for user services.
5. Verify the final unit before installation
Combine the settings into the temporary file. For a clean local validation, use the harmless command /usr/bin/true as the temporary executable, then replace it with your real absolute path before installation:
[Unit]
Description=Example worker with a restricted execution environment
[Service]
Type=exec
ExecStart=/usr/bin/true
DynamicUser=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
NoNewPrivileges=yes
RuntimeDirectory=example-worker
StateDirectory=example-worker
CacheDirectory=example-worker
LogsDirectory=example-worker
Environment="WORKER_MODE=production"
EnvironmentFile=-/etc/example-worker/worker.conf
Run:
$ systemd-analyze verify /tmp/example-worker.service
$ systemd-analyze cat-config systemd.exec
The first command should print nothing for this harmless validation unit. Replace ExecStart= with the real executable and run the same check again. The second command is a reference check for the installed manager's configuration context; it is not a substitute for checking this unit. If verification reports an unknown option, stop and check the installed version rather than silently removing a protection.
6. Install and test with an explicit rollback
Warning
The following commands require elevated privileges and can start a real process. Confirm the executable, account assumptions, writable paths and environment file first.
$ sudo install -o root -g root -m 0644 /tmp/example-worker.service /etc/systemd/system/example-worker.service
$ sudo systemctl daemon-reload
$ sudo systemctl start example-worker.service
$ systemctl --no-pager --full status example-worker.service
$ journalctl -u example-worker.service -n 30 --no-pager
A successful start means systemd launched the process, not that the application completed its job. Check the journal for permission failures and confirm the application is using the managed directory variables. If the start fails, inspect the first relevant error, fix the unit or application assumption, run systemd-analyze verify /etc/systemd/system/example-worker.service again, then retry.
To undo this example safely, stop it before removing its unit:
$ sudo systemctl stop example-worker.service
$ sudo systemctl disable example-worker.service 2>/dev/null || true
$ sudo rm /etc/systemd/system/example-worker.service
$ sudo systemctl daemon-reload
The stop removes the transient runtime directory. It does not remove state, cache or log directories. If you intentionally want to remove those generated directories, review their contents first and use systemctl clean example-worker.service; treat that as a destructive action, not routine cleanup.
Done means
- The unit passes
systemd-analyze verify. - The service runs as a non-root or transient identity appropriate to its design.
- Non-secret configuration comes from explicit settings or a reviewed environment file.
- Writes are limited to managed directories and documented exceptions.
- Journal output confirms the real application starts and can access only what it needs.
- You know the stop and rollback commands before enabling automatic starts.