← All notes

Practical systemd sandboxing for small services

Most internal services run as root because nobody wanted to think about it for twenty minutes. Here are the twenty minutes.

Start with a user, not with directives

Every sandboxing directive in the world is less valuable than simply not being root. Create a system user with no shell and no home:

useradd --system --no-create-home \
        --shell /usr/sbin/nologin \
        --home-dir /nonexistent myservice

The high-value directives

These four give you most of the benefit and rarely break anything:

DirectiveWhat it stops
NoNewPrivileges=truesetuid escalation from inside the process
ProtectSystem=strictwrites anywhere outside /dev, /proc, /sys
ProtectHome=truereading /home, /root, /run/user
PrivateTmp=truetmp file races with other services

ProtectSystem=strict is the one people bounce off, because the service immediately fails to write its own logs or state. The answer is not to weaken it, it is to declare exactly what should be writable:

ProtectSystem=strict
ReadWritePaths=/var/log/myservice /var/lib/myservice

The one that will waste your afternoon

SystemCallFilter=@system-service is good hygiene, but pair it with SystemCallErrorNumber=EPERM and every blocked syscall comes back as a plain permission error. You will spend an hour convinced you have a file ownership problem. If a service dies with a confusing permission denied, comment out the syscall filter first and see if it goes away.

Related trap: if you ever run the binary manually as root to test the config, it may create its log files owned by root. The service then cannot open them as its own user. Same symptom, completely different cause.

Checking your work

systemd-analyze security myservice.service scores the unit and lists what is still open. Do not chase a perfect score. Getting from 9.5 (unsafe) to around 3 covers the realistic threats for an internal daemon.

systemd-analyze security myservice.service

You can also dry-run a directive without touching the unit file at all, which is the fastest way to bisect a broken sandbox:

systemd-run --uid=myservice -p ProtectSystem=strict \
            -p ReadWritePaths=/var/log/myservice \
            --pipe --wait /bin/sh -c 'touch /var/log/myservice/x'