systemd · permissions
systemd service runs as root but gets "Permission denied" (Debian 13)
Published · HardStacked · 8 min
Your unit runs as root. You have checked the permissions on the target ten times, they are correct. The service still dies with Permission denied, or worse, it exits clean and quietly did less than you asked for.
Root under a hardened systemd unit is not the root you learned on a bare install. Hardening directives strip UID 0 of the parts that used to make it root, and neither the unit file nor the exit status tells you which part is missing. Worse, one of them does not produce an error at all.
Everything below was measured on 2026-09-20, on Debian 13.7 (trixie) running systemd 257 (257.13-1~deb13u1) and coreutils 9.7, kernel 6.18.39, on a Raspberry Pi 5 Model B. The directives were applied one at a time with systemd-run property overrides (-p), which changes nothing in any unit file on disk.
A question that never got an accepted answer on Super User sums up exactly where this leaves people:
“I would like to understand what the difference between systemd root and ‘regular’ root is, but can’t find anything online.”
(Super User, question 1939025, 1270 views, still open.) It is a fair question. The honest answer is scattered across a dozen threads, and each one only solves its own half of it.
01. The two-line verdict
Systemd hardening quietly removes powers from root. It never announces the removal anywhere you would think to check. What you see next depends on what the sandbox does to the program. If it forbids an operation outright, the program gets a Permission denied that accuses the wrong thing. If it instead hands the program a valid but emptied view of the filesystem, there is no error for anyone to detect: the program walks what it is given, finds nothing, and exits 0 having done less work than you asked for.
Two unrelated faults produce the identical Permission denied, and a third fault produces no complaint at all. That is the whole article. The rest is where each one comes from and how to tell them apart.
This is not a guide to hardening a systemd unit. That ground is already covered well elsewhere, directive by directive. This page is about the symptom that shows up afterward, on the machine of whoever inherits the hardened unit and has no idea which line in the drop-in changed anything.
02. Two independent causes, one identical denial
The test: a directory owned by UID 1000, holding real files, deleted with /bin/rm -rf launched as root through systemd-run.
These commands are provided as is, without warranty. Test on a non critical machine or a snapshot first.
rm -rf as root, one directive at a time
case on disk systemd reports
witness: plain root, no hardening deleted Result=success, status 0
negative control: /bin/false in place of rm still there Result=exit-code, status 1
PrivateUsers=yes, alone still there Result=exit-code, status 1
CapabilityBoundingSet=/AmbientCapabilities=
both empty, alone still there Result=exit-code, status 1
both of the above, plus ProtectSystem=strict
and a matching ReadWritePaths still there Result=exit-code, status 1
The negative control matters as much as the witness. It proves the failing rows are not just “root under systemd is generally broken”: a plain /bin/false fails on purpose, and everything below it fails for real reasons, not for the same reason as each other.
Here is the message itself, captured from the journal with LC_ALL=C, identical for both causes:
journalctl, LC_ALL=C
rm: cannot remove '/tmp/...': Permission denied
The underlying errno is EACCES, not EPERM. That is worth stating, because a missing capability is the kind of thing you would expect to surface as Operation not permitted, and it does not.
Nothing in the report distinguishes the last three rows. Same Result=exit-code, same status 1, whether root lost its capabilities or lost its view of the UID that owns the files. That is exactly why one forum thread fixes its case by dropping PrivateUsers=yes and calls the problem solved, while another thread, reporting the exact same message, fixes an outwardly identical case by clearing CapabilityBoundingSet=. Both are right, about their own machine, and neither mentions the other cause because it never had to.
One detail buried in that Super User report is worth pulling out: the same failing job could remove an empty directory, just not one holding files. Deleting an empty directory only needs write access on its parent. Deleting a populated one means writing inside it too, against entries whose owning UID root’s current view of the system may not even recognize. That difference is the mechanism in miniature. Section 04 explains why.
03. The dangerous half: a success that did less work
Second service, same host: find /home /root -xdev -type f | wc -l.
find /home /root -xdev -type f | wc -l
config systemd reports files actually counted
witness: plain root Result=success, status 0 110332
ProtectHome=yes Result=success, status 0 0
ProtectHome=read-only Result=success, status 0 110332
ProtectSystem=strict Result=exit-code, status 1 (service fails outright)
The last row needs a footnote, because it does not measure the same thing as the others: ProtectSystem=strict mounts the entire filesystem read-only apart from /dev, /proc and /sys, so this service failed while writing its own result to disk, not while counting. It is the first kind of failure, not the silent one.
ProtectHome=read-only leaves the real /home mounted, only not writable, so the count is the true one. ProtectHome=yes replaces /home with an empty, valid directory before the process ever starts. find walks it end to end and finds nothing, because there is nothing there to find, and walking an empty directory to completion is not an error. One value changed in the unit file, a difference of 110332 files, and zero difference in what systemd reports back to you.
The only way to catch this is to compare against a count taken outside systemd, on the same data, at roughly the same time. systemctl status will not show it. Nothing about the exit code changes. This is the one worth losing sleep over, because it never asks for your attention: no red line in the journal, no failed unit in systemctl --failed, just a number that is quietly wrong in whatever consumed it, a backup job, an inventory script, a compliance report.
04. Why the kernel lies about its own reason
UID 0 and “the powers of root” are two separate things. What lets a process ignore file permissions is a set of capabilities, mainly CAP_DAC_OVERRIDE and CAP_FOWNER. CapabilityBoundingSet= set empty means the process stays UID 0 while holding none of those capabilities. PrivateUsers=yes puts the process in its own user namespace, where UIDs that are not explicitly mapped in appear as nobody, and root has no leverage over files owned by nobody.
Either path ends at the same kernel response: EACCES. The same error code a genuinely wrong file permission produces. The kernel does not say “capability missing” or “UID not mapped”, because from where it stands, a permission check simply failed, and that is the only fact it has to report. The error message lies about its own cause because the kernel was never keeping track of the cause in the first place.
One measurement makes this concrete. Run the same rm as an ordinary unprivileged user, with no systemd involved at all, against the same directory:
same rm, ordinary user, no systemd
rm: cannot remove '/tmp/...': Permission denied
Same sentence. Root under a hardened unit and a user with no privileges whatsoever are indistinguishable from the message alone. If the text cannot separate those two situations, it was never going to point you at a directive in a drop-in file.
05. Isolating instead of guessing
There is no single fix here, because the same symptom has more than one unrelated cause, and section 02 measured two directives that are each sufficient on their own. Promising “the” solution to this problem would be a short, wrong article.
Two moves actually narrow it down. First, run the same command outside systemd, as a plain shell command with sudo, to get a baseline of what the program does when nothing is hardened. Second, remove the hardening directives from the unit one at a time, restarting between each one, until the behavior changes. Because at least two of them are independently sufficient to cause the failure, removing them all at once and declaring victory only tells you that one of them was the problem, not which one, and the drop-in stays half-hardened without anyone noticing.
systemd-analyze security <unit> is worth running, and it will faithfully list the hardening applied. What it will not tell you is what your program stopped being able to see because of it. That gap is not a bug in the tool, it is a description of a different question than the one you are asking.
The same shape runs through the rest of this series, where the system declares one thing and does another: a firewall the systemd unit reports as enabled while it is switched off, a firewall that reports green while blocking nothing, and a WireGuard service that reports active with no interface at all.
The habit worth keeping is small: when a hardened unit reports success, check the actual output of the job, not the exit code of the wrapper that launched it. A clean Result=success tells you the process finished. It has nothing to say about whether it finished the job you meant.