How to read the systemd-analyze security table: not applied and applied rows, exposure score from 0.0 to 10.0 and what the score ignores
| |

Systemd hardening, directive by directive: measuring with systemd-analyze security

Part 1 of a series on systemd hardening: read systemd-analyze security, build a drop-in, test one directive at a time and undo it, with a hands-on lab on a harmless service.

systemd-analyze security is the command I run before I change anything in a systemd unit. It gives each service an exposure score, lists the sandboxing options the unit does not use yet, and shows where to start. This first part covers the method: how drop-ins work, how to read systemd-analyze security, how to test a change without risk, and which services deserve attention first.

This is part 1 of a series of eight on systemd hardening: the next parts go through the directives one family at a time. For each directive, I explain what it does, which services of my stack it affects and what it can break, because that is what a real hardening decision needs. This first part stays on method, because every later part relies on it. You do not need any systemd experience, because each step comes with the full command, a comment on every line and a small lab on a harmless demo service.

Test bench. Every output in this article comes from a real run on Rocky Linux 10.2 with systemd 257, inside a WSL2 virtual machine (kernel 6.18.54.1-microsoft-standard-WSL2, running as root). SELinux is disabled there, so a few kernel features can differ from a normal server. I say so wherever it matters. When I cut lines from an output, I replace them with [...], and I mark commands I did not run as not tested.

Before you start

Read this short list first, because it keeps you from the classic mistake of hardening a production service on the first try.

A test machine. Use a virtual machine or a spare server that runs systemd, so a mistake never reaches production. I ran everything on Rocky Linux 10.2 only; the commands belong to systemd itself, so they should behave the same on other distributions, but I did not test them there.

A shell with sudo rights. Every command that changes the system starts with sudo, so you can see at a glance what modifies the machine.

A harmless service for your first attempts. The lab below creates a demo service named demo-hardening-sandbox.service, so you never touch a real one.

A way to undo everything. Each change lives in one drop-in file, and deleting that file undoes it. Then the lab ends with a clean-up step that removes every file it created.

A recent systemd. Several options of systemd-analyze security were added in version 250, so check yours with systemctl --version.

A short glossary

TermWhat it means here
UnitA file that describes something systemd manages. A service unit ends in .service.
ServiceA unit that runs a program: a daemon that stays up, or a command that runs once and exits.
Drop-inA small .conf file in a .d directory that changes a unit without editing the original file.
DirectiveOne setting of a unit file, such as ProtectSystem=strict.
SandboxingThe directives that restrict what a service can see and do.
NamespaceA kernel feature that gives a process its own view of the system, for example its own mount points. Several directives rely on it.
CapabilityOne slice of root’s powers, for example binding a low port, that the kernel can grant separately.
System callA request from a program to the kernel, such as opening a file.
Exposure levelThe score that systemd-analyze security computes for a unit.
Transient unitA temporary unit that systemd-run creates on the fly; it disappears when it stops.

At a glance

StepCommandWhat it tells you
Score every running servicesystemd-analyze securityOne line per loaded service with an exposure level
Detail one servicesystemd-analyze security UNITEach setting, applied (✓) or not (✗), with its exposure
Check a file before deployingsystemd-analyze verify FILE and security --offline=true FILESyntax errors, then the offline review
Change one directivesystemctl edit UNITA drop-in file, reloaded when you save
Experiment without editingsystemd-run -p DIRECTIVE=VALUE ...A temporary unit with the properties you choose
Read a failuresystemctl status UNIT and journalctl -u UNITThe exit code, then the service’s own messages
Roll backsystemctl revert UNITRemoves the drop-ins of the unit

Why harden services with systemd

A service that has no sandboxing directive runs without any of those protections. The systemd.exec manual calls the sandboxing options an effective way to limit the exposure of the system, and it recommends turning on as many of them as possible without breaking the process. My demo service shows the starting point: with no sandboxing at all, systemd-analyze security rates it 9.6 and labels it UNSAFE, with 75 settings not applied and 4 applied (you will measure it yourself in the lab).

Sandboxing does not replace the other layers of defence. However, it adds one that costs nothing to install: a few lines in a drop-in. Two limits from the manual are worth remembering. First, many options are gracefully turned off on systems where the underlying mechanism is not available. Second, some sandboxing functionality is generally not available in user services.

How a unit and its drop-ins fit together

A drop-in is the safest way to change a service, because you never edit the file that a package installed. Instead, you add a small file next to it, and systemd merges the two. The diagram below shows which file wins.

Drop-in layering of a systemd unit: package file, runtime drop-ins and your own drop-in merged into the effective unit
Which file wins when a systemd unit and its drop-ins are merged.

Where drop-ins live and which one wins

For a unit named foo.service, systemd looks for a directory named foo.service.d/. It reads only the files that end in .conf, and it merges them in alphanumeric order after it has parsed the main unit file. Each drop-in must contain its own section header, for example [Service].

LocationRolePrecedence
/usr/lib/systemd/system/Units and drop-ins shipped by packagesLowest
/run/systemd/system/Runtime changes, lost at rebootMiddle
/etc/systemd/system/Your own units and drop-insHighest

The manual adds three rules. First, drop-ins in /etc/ win over those in /run/, which win over those in /usr/lib/. Second, drop-ins win over unit files wherever those live. Third, at the same level, files with different names apply in lexicographic order. That last rule is why I name my files with an obvious purpose, such as hardening.conf.

Create, check and undo a drop-in

The lab below runs the real commands in order, with their output: systemctl edit to create a drop-in, systemctl cat to see what systemd reads, systemctl show to see what it loaded, and systemctl revert to undo it. One point deserves attention here. systemctl edit reloads the configuration for you, and a reload keeps the runtime state. Therefore a service that is already running keeps its old sandbox until you restart it. Mistake 4 in the lab proves it.

Reading systemd-analyze security

The systemd-analyze security tool analyses the security and sandboxing settings of service units. It does not start anything. Instead, it reads the settings and compares them with the protections that systemd can apply.

How to read the systemd-analyze security table: not applied and applied rows, exposure score from 0.0 to 10.0 and what the score ignores
Reading the output of systemd-analyze security, row by row.

One service or the whole system

Without a unit name, systemd-analyze security inspects all loaded, long-running services and prints a terse table, one line per unit. With one or more unit names, however, it prints a detailed analysis of each. Here is the whole-system table, cut after four lines:

systemd-analyze security --no-pager
UNIT                                 EXPOSURE PREDICATE HAPPY
NetworkManager.service                    7.8 EXPOSED   🙁
auditd.service                            9.4 UNSAFE    😨
cockpit-wsinstance-http.service           5.4 MEDIUM    😐
cockpit.service                           6.2 MEDIUM    😐
[...]

The columns are the unit, its exposure level, a label (the “predicate”) and a face. On the test bench I saw the labels MEDIUM, EXPOSED and UNSAFE, but the manual does not define them. So use the number, and treat the label as a quick visual cue.

The table, row by row

For one service, the output has three columns: NAME, DESCRIPTION and EXPOSURE. Each row starts with a mark, where ✗ means the setting is not applied and ✓ means it is. Finally, the last line gives the overall result. Here is the beginning of the table for the demo service of the lab, before any sandboxing:

systemd-analyze security --no-pager demo-hardening-sandbox.service | head -n 6
  NAME                                                        DESCRIPTION                                                             EXPOSURE
✗ RootDirectory=/RootImage=                                   Service runs within the host's root directory                                0.1
  SupplementaryGroups=                                        Service runs as root, option does not matter
  RemoveIPC=                                                  Service runs as root, option does not apply
✗ User=/DynamicUser=                                          Service runs as root user                                                    0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME                         Service processes may change the system clock                                0.2

Read it like a to-do list, because every ✗ row is a protection you could still add, and the number on the right says how much exposure it would remove. Rows without a mark and without a number do not apply to this unit: for instance, the tool tells you that RemoveIPC= does not matter when the service runs as root. To keep only the work left to do, filter the output:

systemd-analyze security --no-pager demo-hardening-sandbox.service | grep '✗' | head -n 4
✗ RootDirectory=/RootImage=                                   Service runs within the host's root directory                                0.1
✗ User=/DynamicUser=                                          Service runs as root user                                                    0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME                         Service processes may change the system clock                                0.2
✗ NoNewPrivileges=                                            Service processes may acquire new privileges                                 0.2

What the score means, and what it does not

The manual describes the overall exposure level as an estimation from 0.0 to 10.0. A high score means little sandboxing, and a low score means tight restrictions. Be careful with that number, because the manual is explicit about its limits:

It covers only the per-service security features that systemd itself implements, so it ignores what the program does to protect itself, and it ignores SELinux.

A high score does not mean the service is vulnerable, and a low score does not prove it is safe. Instead, it only says the service might benefit from more settings.

Many settings can be undone individually by other settings, but the tool accounts for some combinations only.

Settings apply to the service’s own operations, while requests sent over IPC, such as D-Bus, can reach services that are not restricted the same way, so you must check the IPC access policy separately.

In practice, treat the systemd-analyze security score as a compass, not as a goal. Indeed, it helps you find services with no protection at all, and it confirms that a change had the effect you wanted.

Options worth knowing

OptionWhat it doesTested here
--offline=BOOLReviews unit files without asking the running manager (added in version 250)Yes: works on a file path and on a unit name
--threshold=NUMBERReturns an error when the overall level exceeds the value (added in 250)Yes: exit code 1 above the threshold, 0 below it
--json=MODEPrints the table as JSON: off, pretty or shortYes: short
--security-policy=PATHApplies your own JSON requirements; a weight of 0 means “do not check” (added in 250)Not tested
--profile=PATHWith --offline, takes a portable profile into account (added in 250)Not tested
--no-pagerPrints directly instead of using a pagerYes

Here are the real results for the demo service once the lab drop-in is in place (its score is 8.9, as step 3 of the lab shows). I ran --threshold twice to check the exit code:

systemd-analyze security --offline=true --no-pager /etc/systemd/system/demo-hardening-sandbox.service | tail -n 1
systemd-analyze security --offline=true --no-pager demo-hardening-sandbox.service | tail -n 1
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
systemd-analyze security --threshold=3 --no-pager demo-hardening-sandbox.service | tail -n 1; echo "exit=${PIPESTATUS[0]}"
systemd-analyze security --threshold=100 --no-pager demo-hardening-sandbox.service | tail -n 1; echo "exit=${PIPESTATUS[0]}"
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
exit=1
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
exit=0

The JSON mode gives one object per row, with the fields set, name, json_field, description and exposure. Here are the first 330 characters:

systemd-analyze security --json=short --no-pager demo-hardening-sandbox.service | head -c 330
[{"set":false,"name":"RootDirectory=/RootImage=","json_field":"RootDirectoryOrRootImage","description":"Service runs within the host's root directory","exposure":"0.1"},{"set":null,"name":"SupplementaryGroups=","json_field":"SupplementaryGroups","description":"Service runs as root, option does not matter","exposure":null},{"set"
[...]

One detail deserves a warning. The manual says the overall level runs from 0.0 to 10.0, yet it documents a default threshold of 100, and it says --offline returns an error above that threshold. So the default never triggers. If you use --threshold in a script, pass an explicit value and test both sides of it, as I did above.

Check a unit before you deploy it

Two commands help you before a file goes into /etc/systemd/system/. systemd-analyze verify detects unknown sections and directives, missing dependencies, missing manual pages named in Documentation=, and commands in ExecStart= that are not found or not executable. Then the offline mode of systemd-analyze security (--offline=true) reviews the file without asking the running manager. Here is a clean run on the lab unit:

systemd-analyze verify /etc/systemd/system/demo-hardening-sandbox.service; echo "exit=$?"
exit=0

No output and exit code 0 mean that verify found nothing to report. However, the manual has no example for the offline review of a file path. It works on my test bench, but test it on a copy before you rely on it.

A safe hardening loop

Hardening goes wrong when you change five things at once and cannot tell which one broke the service. The loop below avoids that. It starts with systemd-analyze security, then it adds one directive at a time, and it ends with a test of the real job of the service.

Hardening loop for a systemd service: measure, change one directive, restart, read the result, test the real job, keep or roll back
The hardening loop: one directive at a time, then keep it or roll it back.
  1. Measure. Run systemd-analyze security UNIT and note the ✗ rows you plan to fix.
  2. Change one directive in a drop-in with sudo systemctl edit UNIT.
  3. Restart with sudo systemctl restart UNIT. A reload alone does not give a running service its new sandbox.
  4. Read the result with systemctl status UNIT and journalctl -u UNIT.
  5. Test the real job: log in, upload a file, run the backup, send a mail. A service can start and still fail later.
  6. Keep or roll back. Keep the drop-in in your configuration management, or delete it, run sudo systemctl daemon-reload and restart.

The last two steps matter most. A sandbox can leave a service able to start while one of its jobs fails later, for example when it needs a path that is now read-only. That is why the loop ends with a test of the real job and not with a successful start.

Reading a failure

When a sandbox breaks a service, the exit code and the service’s own message tell you which directive to loosen. Here is a demo unit that runs with ProtectSystem=strict and tries to create a file in /etc. The status output is cut after the summary, because the journal lines that follow carry the name of my test machine:

sudo systemctl start demo-hardening-fail.service
systemctl status --no-pager -n 5 demo-hardening-fail.service
× demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless)
     Loaded: loaded (/etc/systemd/system/demo-hardening-fail.service; static)
     Active: failed (Result: exit-code) since Mon 2026-11-02 14:54:40 CET; 35ms ago
 Invocation: 55376c422cb741a69786c3968cc455a2
    Process: 2008 ExecStart=/bin/touch /etc/demo-hardening-fail (code=exited, status=1/FAILURE)
   Main PID: 2008 (code=exited, status=1/FAILURE)
   Mem peak: 1.2M
        CPU: 5ms
[...]

Then the journal shows the message of the program itself. -u selects the unit, -o cat prints only the message, and -n 6 limits the number of lines:

journalctl -u demo-hardening-fail.service -o cat --no-pager -n 6
Starting demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless)...
/bin/touch: cannot touch '/etc/demo-hardening-fail': Read-only file system
demo-hardening-fail.service: Main process exited, code=exited, status=1/FAILURE
demo-hardening-fail.service: Failed with result 'exit-code'.
Failed to start demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless).

The message says Read-only file system, so the directive responsible is ProtectSystem=. The fix is to allow the path the program really needs with ReadWritePaths=, which part 2 of the series covers.

Hands-on lab: see three directives at work

This lab takes about fifteen minutes. It uses a demo service that only lists, tests and touches files, and it removes everything at the end. Every output below is a real one from the test bench, and lines that start with --- in the outputs are labels that my test script printed between commands.

Step 1: create the demo service and run it without a sandbox

Create the file /etc/systemd/system/demo-hardening-sandbox.service with your editor, for example sudo nano /etc/systemd/system/demo-hardening-sandbox.service. Lines that start with # are comments, and you should always put a comment on its own line, never after a setting.

# A harmless demo: it counts /home entries, tests a write in /etc and touches a file in /tmp.
[Unit]
Description=Sandbox demo service (harmless)

[Service]
# oneshot: systemd waits for the command to finish; the unit never stays "active".
Type=oneshot
# systemd does not run a shell by itself, so the command starts /bin/sh explicitly.
ExecStart=/bin/sh -c 'echo "home entries:"; ls -A /home | wc -l; touch /etc/demo-hardening-test && rm /etc/demo-hardening-test && echo "etc: write ok" || echo "etc: write refused"; touch /tmp/demo-hardening-marker; echo "done"'

Now tell systemd to read the new file, then show it, run the service and read its output. A oneshot service runs once, and systemctl start waits for it to finish:

sudo systemctl daemon-reload
systemctl cat demo-hardening-sandbox.service
sudo systemctl start demo-hardening-sandbox.service
journalctl -u demo-hardening-sandbox.service -o cat --no-pager
ls -l /tmp/demo-hardening-marker | awk '{print $1, $3, $NF}'
Starting demo-hardening-sandbox.service - Sandbox demo service (harmless)...
home entries:
1
etc: write ok
done
demo-hardening-sandbox.service: Deactivated successfully.
Finished demo-hardening-sandbox.service - Sandbox demo service (harmless).
-rw-r--r-- root /tmp/demo-hardening-marker

Read the result: the service saw one entry in /home, it could write in /etc, and it created its marker file in the real /tmp. So nothing is protected yet. Measure the starting point:

systemd-analyze security --no-pager demo-hardening-sandbox.service | tail -n 1
systemd-analyze security --no-pager demo-hardening-sandbox.service | grep -c '✗'
systemd-analyze security --no-pager demo-hardening-sandbox.service | grep -c '✓'
→ Overall exposure level for demo-hardening-sandbox.service: 9.6 UNSAFE 😨
75
4

Finally, look at the three rows that the next step will fix:

systemd-analyze security --no-pager demo-hardening-sandbox.service | grep -E 'ProtectSystem|ProtectHome|PrivateTmp'
✗ ProtectSystem=                                              Service has full access to the OS file hierarchy                             0.2
✗ ProtectHome=                                                Service has full access to home directories                                  0.2
✗ PrivateTmp=                                                 Service has access to other software's temporary files                       0.2

Step 2: add the drop-in

The normal way is the interactive editor: sudo systemctl edit demo-hardening-sandbox.service opens a new file named override.conf (not tested here, because it needs a terminal). To keep the lab reproducible, use the non-interactive form. --stdin reads the content from standard input, and --drop-in=hardening chooses the file name:

sudo systemctl edit --stdin --drop-in=hardening demo-hardening-sandbox.service <<'EOF'
[Service]
# The whole file system becomes read-only for this service (except /dev, /proc and /sys).
ProtectSystem=strict
# /home, /root and /run/user become inaccessible and empty.
ProtectHome=true
# /tmp and /var/tmp become private to this service and are removed when it stops.
PrivateTmp=true
EOF

Each line of that file does one thing. First, [Service] is the required section header. Then ProtectSystem=strict mounts the whole file system hierarchy read-only, except /dev, /proc and /sys. Also, ProtectHome=true makes /home, /root and /run/user inaccessible and empty. Finally, PrivateTmp=true gives the service its own /tmp and /var/tmp, removed when it stops. Systemd answers with the path of the new file:

Successfully installed edited file '/etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf'.

Now check what systemd reads and what it loaded. The output of systemctl cat starts with the main unit file, so I cut that part:

systemctl cat demo-hardening-sandbox.service
systemctl show --property=ProtectSystem --property=ProtectHome --property=PrivateTmp demo-hardening-sandbox.service
[...]
# /etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf
[Service]
# The whole file system becomes read-only for this service (except /dev, /proc and /sys).
ProtectSystem=strict
# /home, /root and /run/user become inaccessible and empty.
ProtectHome=true
# /tmp and /var/tmp become private to this service and are removed when it stops.
PrivateTmp=true
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict

systemctl show prints yes where the file says true, because both mean the same boolean. The three last lines are the values that systemd loaded, and the three before them are the file.

Then run the service again and compare with step 1:

rm -f /tmp/demo-hardening-marker
sudo systemctl start demo-hardening-sandbox.service
journalctl -u demo-hardening-sandbox.service -n 7 -o cat --no-pager
ls -l /tmp/demo-hardening-marker
home entries:
0
touch: cannot touch '/etc/demo-hardening-test': Read-only file system
etc: write refused
done
demo-hardening-sandbox.service: Deactivated successfully.
Finished demo-hardening-sandbox.service - Sandbox demo service (harmless).
ls: cannot access '/tmp/demo-hardening-marker': No such file or directory

Three things changed, and they match the manual. First, the service now sees 0 entries in /home. Second, its write in /etc fails with Read-only file system. Third, the marker file it created is not visible from the host, because the service used a private /tmp.

Step 3: measure again

systemd-analyze security --no-pager demo-hardening-sandbox.service | tail -n 1
systemd-analyze security --no-pager demo-hardening-sandbox.service | grep -E 'ProtectSystem|ProtectHome|PrivateTmp'
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
✓ ProtectSystem=                                              Service has strict read-only access to the OS file hierarchy
✓ PrivateTmp=                                                 Service has no access to other software's temporary files
✓ ProtectHome=                                                Service has no access to home directories                               

The systemd-analyze security score fell from 9.6 UNSAFE to 8.9 EXPOSED, the three rows turned from ✗ to ✓, and the counts moved from 75 not applied and 4 applied to 71 and 8. Three directives gave a modest gain, which is normal: a root service keeps many other exposures, such as the user it runs as and its capabilities. Parts 3 and 4 of the series address those.

Step 4: try one directive at a time with systemd-run

systemd-run starts a command as a transient unit, and -p sets a property in the same format as systemctl set-property. The options below are the documented ones: --quiet hides the start message, --pipe connects the command to your terminal’s input and output, --wait waits for the end and --collect unloads the unit afterwards, even after a failure. (The manual also documents -t for an interactive shell; I did not test it.) The manual has no sandboxing example for systemd-run, so the results below are my own tests. The echo lines print the labels that you see in the output, and each ls checks the real file system afterwards:

RUN="sudo systemd-run --quiet --pipe --wait --collect"
echo "--- no sandbox: write to /etc"; $RUN /bin/touch /etc/demo-hardening-t1; echo "exit=$?"; ls /etc/demo-hardening-t1; rm -f /etc/demo-hardening-t1
echo "--- ProtectSystem=strict: write to /etc"; $RUN -p ProtectSystem=strict /bin/touch /etc/demo-hardening-t2; echo "exit=$?"; ls /etc/demo-hardening-t2 2>&1
echo "--- ProtectSystem=true: write to /etc, then to /usr"
$RUN -p ProtectSystem=true /bin/touch /etc/demo-hardening-t3; echo "etc exit=$?"; ls /etc/demo-hardening-t3; rm -f /etc/demo-hardening-t3
$RUN -p ProtectSystem=true /bin/touch /usr/demo-hardening-t4; echo "usr exit=$?"; ls /usr/demo-hardening-t4 2>&1
echo "--- ProtectSystem=full: write to /etc"; $RUN -p ProtectSystem=full /bin/touch /etc/demo-hardening-t5; echo "exit=$?"; ls /etc/demo-hardening-t5 2>&1
echo "--- /home entries seen by the service, without and with ProtectHome=true"
$RUN /bin/sh -c 'ls -A /home | wc -l'
$RUN -p ProtectHome=true /bin/sh -c 'ls -A /home | wc -l'
echo "--- ProtectHome=true: ls -ld /home inside the service"; $RUN -p ProtectHome=true /bin/ls -ld /home | awk '{print $1, $3, $4}'
echo "--- PrivateTmp=true: file created in /tmp by the service, seen from the host?"
rm -f /tmp/demo-hardening-t6
$RUN -p PrivateTmp=true /bin/touch /tmp/demo-hardening-t6; echo "service exit=$?"; ls /tmp/demo-hardening-t6 2>&1
echo "--- without PrivateTmp, same test"
$RUN /bin/touch /tmp/demo-hardening-t7; echo "service exit=$?"; ls /tmp/demo-hardening-t7 2>&1; rm -f /tmp/demo-hardening-t7
--- no sandbox: write to /etc
exit=0
/etc/demo-hardening-t1
--- ProtectSystem=strict: write to /etc
/bin/touch: cannot touch '/etc/demo-hardening-t2': Read-only file system
exit=1
ls: cannot access '/etc/demo-hardening-t2': No such file or directory
--- ProtectSystem=true: write to /etc, then to /usr
etc exit=0
/etc/demo-hardening-t3
/bin/touch: cannot touch '/usr/demo-hardening-t4': Read-only file system
usr exit=1
ls: cannot access '/usr/demo-hardening-t4': No such file or directory
--- ProtectSystem=full: write to /etc
/bin/touch: cannot touch '/etc/demo-hardening-t5': Read-only file system
exit=1
ls: cannot access '/etc/demo-hardening-t5': No such file or directory
--- /home entries seen by the service, without and with ProtectHome=true
1
0
--- ProtectHome=true: ls -ld /home inside the service
d--------- root root
--- PrivateTmp=true: file created in /tmp by the service, seen from the host?
service exit=0
ls: cannot access '/tmp/demo-hardening-t6': No such file or directory
--- without PrivateTmp, same test
service exit=0
/tmp/demo-hardening-t7

This table reads the results the way the manual describes them:

DirectiveWhat my test showedManual
ProtectSystem=trueWrite to /etc works, write to /usr is refused/usr and the boot loader directories read-only
ProtectSystem=fullWrite to /etc is refused/etc read-only as well
ProtectSystem=strictWrite to /etc is refusedThe whole hierarchy read-only, except /dev, /proc, /sys
ProtectHome=true0 entries seen, and /home shows mode d---------Directories inaccessible and empty
PrivateTmp=trueThe file created by the service is absent on the host/tmp and /var/tmp not shared with processes outside

These results confirm the manual on my test bench. They come from a WSL2 kernel, so confirm them on your own server before you rely on a directive in production.

Step 5: undo and clean up

systemctl revert removes the drop-ins of a unit. It leaves a unit file that has no vendor version, such as our demo file. Then delete the demo files and tell systemd:

sudo systemctl revert demo-hardening-sandbox.service
sudo rm -f /etc/systemd/system/demo-hardening-sandbox.service
sudo rm -f /tmp/demo-hardening-marker
sudo systemctl daemon-reload
Removed '/etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf'.
Removed '/etc/systemd/system/demo-hardening-sandbox.service.d'.

The first command printed the two lines above. After the revert, the unit file stayed and the .d directory was gone. After the clean-up, I checked that no demo unit, no file in /tmp, /etc or /usr, and no loaded demo unit remained (all counts were 0).

Common mistakes

Each mistake below happened on the test bench, with the real message. I reproduced them on a throwaway unit named demo-hardening-mistakes.service (a oneshot service that runs /bin/true) and on a sleep service, then deleted both.

Mistake 1: a drop-in without a section header

The drop-in a-no-header.conf contained only ProtectSystem=strict. systemd ignored the line, and the loaded value stayed no:

systemctl show --property=ProtectSystem demo-hardening-mistakes.service
journalctl -b -o cat --no-pager --since "-1min" | grep -i a-no-header | sort -u
ProtectSystem=no
/etc/systemd/system/demo-hardening-mistakes.service.d/a-no-header.conf:1: Assignment outside of section. Ignoring.

Mistake 2: a drop-in without the .conf suffix

A file named b-wrong-suffix.txt is never read. systemctl cat lists only the main file, and the value stays no:

systemctl cat demo-hardening-mistakes.service | grep '^#'
systemctl show --property=ProtectSystem demo-hardening-mistakes.service
# /etc/systemd/system/demo-hardening-mistakes.service
ProtectSystem=no

Mistake 3: a comment on the same line as a setting

I wrote ProtectSystem=strict # make everything read-only on one line. systemd read the whole text as the value, rejected it and kept the default:

systemctl show --property=ProtectSystem demo-hardening-mistakes.service
journalctl -b -o cat --no-pager --since "-1min" | grep -i c-inline-comment | sort -u
ProtectSystem=no
/etc/systemd/system/demo-hardening-mistakes.service.d/c-inline-comment.conf:2: Failed to parse ProtectSystem=strict # make everything read-only, ignoring: Invalid argument

Mistake 4: a reload without a restart

A sleep service was already running. I added ProtectSystem=strict and ran daemon-reload. systemctl show reported strict, but the process kept the same PID and the same mount namespace as PID 1. Only the restart gave it a new PID and its own namespace:

PID=$(systemctl show --property=MainPID --value demo-hardening-sleep.service)
readlink /proc/1/ns/mnt
readlink /proc/$PID/ns/mnt

I ran those three lines before the change, after the reload and after the restart. The labelled results are:

--- mount namespace of PID 1 and of the service
pid1:    mnt:[4026532218]
service: mnt:[4026532218]
--- after drop-in + daemon-reload (no restart)
show ProtectSystem: strict
same PID: 1886 (was 1886)
service: mnt:[4026532218]
--- after systemctl restart
new PID: 1948
pid1:    mnt:[4026532218]
service: mnt:[4026532287]

Other mistakes to avoid

Editing the packaged file in /usr/lib/systemd/system/. Use a drop-in in /etc/systemd/system/ instead; the manual gives /etc/ precedence over /usr/lib/.

Judging a change by “the service starts”. Test the real job, as in the loop above.

Changing several directives at once. Otherwise, you will not know which one broke the service.

Chasing a score of 0. The manual says a high score does not mean a vulnerable service. Use the systemd-analyze security score to prioritise.

Using mount-namespace directives in a user service. The manual warns that some sandboxing functionality is generally not available in user services.

A checklist you can follow

  1. Work on a test machine or a harmless service first.
  2. Run systemd-analyze security UNIT and note the score and the ✗ rows.
  3. Write one directive in a drop-in under /etc/systemd/system/UNIT.d/, in a .conf file with a [Service] header and comments on their own lines.
  4. Run systemctl cat UNIT to check that systemd reads your file.
  5. Run sudo systemctl restart UNIT; a reload alone is not enough for a running service.
  6. Read systemctl status UNIT and journalctl -u UNIT.
  7. Test the real job of the service, not only its start.
  8. Measure again with systemd-analyze security UNIT.
  9. Keep the drop-in in your configuration management, or remove it with systemctl revert and restart.
  10. Repeat with the next directive.

The directive families of the next parts

The next parts take each family in turn. Every directive name below is documented in the systemd.exec manual page; IPAddressAllow= and IPAddressDeny= are documented in systemd.resource-control.

PartFamilyDirectives coveredQuestion it answers
2FilesystemProtectSystem, ProtectHome, PrivateTmp, ReadWritePaths, ReadOnlyPaths, InaccessiblePaths, TemporaryFileSystem, BindPathsWhat may the service read and write?
3PrivilegesNoNewPrivileges, User, DynamicUser, CapabilityBoundingSet, AmbientCapabilities, SecureBits, RestrictSUIDSGIDWhat can the process become?
4Kernel and devicesProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectClock, ProtectControlGroups, PrivateDevices, ProtectProc, ProcSubsetWhat can it change in the kernel?
5Namespaces and networkPrivateNetwork, PrivateUsers, RestrictNamespaces, RestrictAddressFamilies, IPAddressAllow, IPAddressDenyWho can it talk to?
6System callsSystemCallFilter, SystemCallArchitectures, SystemCallErrorNumberWhich requests may it send to the kernel?
7Memory and miscellaneousMemoryDenyWriteExecute, LockPersonality, RestrictRealtime, UMask, ProtectHostname, RemoveIPC, KeyringModeWhat odd tricks can it still use?
8The whole stackA matrix of every directive against every serviceWhat do I apply where?

My stack, and where to start

Hardening needs a threat model, even a simple one. For this reason, I assume that an attacker has already taken control of one service process, and I ask what that process could do next: read secrets, write a persistence mechanism, reach other services, or gain more privileges. Then each directive removes one of those options.

My servers run a typical self-hosted stack, which the later parts will cover service by service:

a reverse proxy in front of every public application, with its CrowdSec bouncer;

PHP-FPM applications, plus Node.js, Python, Go and .NET applications (a media server and a library server);

MariaDB, PostgreSQL and Valkey, with pgBackRest for PostgreSQL backups;

a mail server, an SFTP and WebDAV server, and a team chat;

security and monitoring agents: a SIEM agent, brute-force protection, an IDS, and metric exporters;

a VPN and a web administration console.

I start with the services that combine three criteria: they are reachable from the Internet, directly or behind the reverse proxy; third parties wrote them; and they have no SELinux policy of their own. Those are the web applications. The databases and the agents come after, because the network cannot reach them directly.

What I take away

Measure before you change. systemd-analyze security gives a score and a to-do list, and it needs no change to the service.

Use drop-ins. They survive package updates better than edits to the packaged file, and deleting one is also the rollback.

Restart after a change. Otherwise, a running process stays in its old sandbox.

Change one directive at a time and test the real job of the service.

Treat the score as a compass. It is only a compass because it counts only what systemd itself restricts.

Test the claims. Every result in this article comes from a real run, so run the lab yourself and compare.

Sources

systemd-analyze(1): freedesktop.org manual page

systemd.unit(5): freedesktop.org manual page

systemd.exec(5): freedesktop.org manual page

systemd.service(5): freedesktop.org manual page

systemd.resource-control(5): freedesktop.org manual page

systemctl(1): freedesktop.org manual page

systemd-run(1): freedesktop.org manual page

journalctl(1): freedesktop.org manual page

Related articles

Under the hood of the October 2026 flaws: one byte too many, a forgotten pointer, a shared page

ZDI-CAN-33367: when an avoided copy writes into a read-only file

CrowdSec 1.8 on Debian: leaving the distro package, the postinst trap and a protection checklist

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *