Build a Safer systemd Service Environment with systemd.exec
Most services still run as root with the whole filesystem writable underneath. systemd.exec is how you stop doing that.
The route
Jump straight to the step you need, or tick off Done means at the end.
You will finish with a service unit that runs as its own identity, gets managed writable directories, and cannot casually touch the rest of the host filesystem, verified before it can disrupt anything real. The examples match the installed systemd.exec(5) from systemd package version 255.4-1ubuntu8.17, whose manual identifies itself as systemd 255.
Give it 20 minutes. You need a Linux host using systemd, a program that can run as a service, and admin access for the final install. The guide uses /usr/local/bin/example-worker as an obvious placeholder; swap it for a real absolute path before you start anything.
Checkpoint
The examples start with a temporary unit and a syntax check. Nothing restarts until the later step that explicitly needs elevated privileges.
1. Start with a small unit
Create a working copy outside the system unit directories. This is an ordinary shell command touching 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= set the Unix identity the process runs as. Leave them out for a system service and the default is root, which is a poor place to start for anything network-facing or file-processing. The account has to exist already unless you use DynamicUser=yes; this guide uses a named account because ownership and auditing stay easy to follow.
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 warning is expected until the account exists. A real executable can also throw a warning or error if its path is wrong. An empty result here is the good result: the unit passed syntax and dependency checks.
2. Add configuration without accidental expansion
Put non-secret, stable values in Environment=. systemd parses the assignment but never performs shell variable expansion inside it, so the dollar sign in $HOME below stays a literal dollar sign:
[Service]
Environment="WORKER_MODE=production" "WORKER_PATTERN=$HOME/*.job"
Environment="WORKER_LABEL=nightly batch"
EnvironmentFile=-/etc/example-worker/worker.conf
Values on one Environment= line are separate assignments. Quote the whole assignment when its value has spaces or an equals sign. Repeating a setting is fine; the 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.
That file is not a shell script. It is newline-separated assignments, comments allowed, but quoting follows systemd's own rules, not the shell's. A minimal example:
# /etc/example-worker/worker.conf
WORKER_ENDPOINT=https://127.0.0.1:9443/jobs
WORKER_BATCH_SIZE=25
Keep passwords, private keys and other secrets out of either setting. The manual is explicit that unit environment variables are exposed through D-Bus and inherited by child processes. Use systemd credentials, LoadCredential= or LoadCredentialEncrypted=, for anything sensitive instead.
Common trap: system services do not automatically inherit the system manager's own environment. If a value genuinely needs to come from there, PassEnvironment=NAME can pass it through, but explicit unit configuration is usually easier to audit later. UnsetEnvironment=NAME applies last and can strip a value supplied by any of these sources.
3. Give the service directories it is actually allowed to write
Do not open up write access to a whole directory tree because a program needs to write one file. Let systemd create purpose-built directories and hand their paths over as 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 create directories under /run, /var/lib, /var/cache and /var/log respectively, and systemd sets RUNTIME_DIRECTORY, STATE_DIRECTORY, CACHE_DIRECTORY and LOGS_DIRECTORY to the full paths. Use those variables in the program itself where you can, or map them onto its own settings where you cannot.
The runtime directory disappears when the unit stops, unless RuntimeDirectoryPreserve= says otherwise. State, cache and log directories stick around. That difference is deliberate: a restart should not quietly wipe state, while a socket or PID directory usually should come back fresh. These settings also add whatever mount dependencies are needed to reach the paths.
For a dynamic user, managed directories also dodge the ownership hazards of a recycled numeric ID. Do not leave files owned by a dynamic identity scattered across shared directories.
4. Add filesystem boundaries one at a time
Now layer on the protections your program can actually tolerate. A reasonable baseline for a long-running service:
[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 whole filesystem read-only to the service except for the API filesystems, and ReadWritePaths= is your allow-list of exceptions. ProtectHome=read-only keeps home directories visible but unwritable. PrivateTmp=yes gives the service its own temporary directories. NoNewPrivileges=yes stops the process and its children gaining privileges through execution.
DynamicUser=yes allocates a transient user and group, and quietly implies private temporary storage, NoNewPrivileges=, protection against SUID and SGID changes, ProtectSystem=strict and ProtectHome=read-only on its own. Keep the explicit settings anyway while you test: they make the boundary visible, and the paths document your assumptions for whoever reads this unit next.
These are security controls, not decoration. A program that needs to touch /etc, look inside user home directories, create device nodes or use a specific kernel interface may simply fail under this. Add only the narrow exception you can explain out loud. Some sandbox settings are unavailable or silently ineffective if the kernel or service manager cannot provide the underlying namespacing feature, particularly for user services.
5. Verify the final unit before installing it
Combine everything into the temporary file. For a clean local check, use the harmless /usr/bin/true as the placeholder executable, then swap in the 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. Swap in the real executable and run it again. The second is a reference check on the installed manager's own configuration context, not a substitute for checking this unit. If verification flags an unknown option, stop and check the installed version rather than quietly dropping a protection to make the warning go away.
6. Install and test with an explicit rollback ready
Warning
The following commands need elevated privileges and start a real process. Confirm the executable, account assumptions, writable paths and environment file before running them.
$ 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 finished its job. Check the journal for permission failures and confirm the application is actually using the managed directory variables. If the start fails, look at the first relevant error, fix the unit or the assumption behind it, rerun systemd-analyze verify /etc/systemd/system/example-worker.service, then try again.
To undo this safely, stop it before removing the 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
Stopping the unit removes its transient runtime directory, but not its state, cache or log directories. If you genuinely want those generated directories gone, review their contents first and run systemctl clean example-worker.service; treat that as a destructive action, not routine tidying up.
Done means
- The unit passes
systemd-analyze verify. - The service runs as a non-root or transient identity that fits 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 reach only what it needs.
- You know the stop and rollback commands before you enable automatic starts.