Docker gets all the security attention, but most homelabs run plenty of services directly on the host — Nginx, Prometheus, systemd-networkd, fail2ban, cron jobs, custom scripts. Each one is a systemd unit, and each one runs with default permissions that are far too permissive.
systemd’s [Service] section supports dozens of sandboxing directives
that clamp down on what a process can see, touch, and do. Most homelab
servers don’t use any of them.
This guide covers the exact directives you add to existing unit files
or new .service files to lock down services on Debian, Ubuntu, or any
systemd-based Linux distribution.
Why Default systemd Permissions Are Dangerous
Without hardening, every service on your system runs with full access to the filesystem, the process tree, the network, and kernel interfaces.
|
|
A single vulnerable service — say, a custom API script that suffers from path traversal — can read every file on the system, spawn processes, and persist itself. systemd hardening limits the blast radius.
The approach: start with the most restrictive settings, then relax only what breaks. Like Docker capabilities, it’s easier to add back than to lock down after deployment.
Hardening Level 1: Filesystem Sandboxing
systemd provides directives that restrict what parts of the filesystem a service can access. These are your first line of defense.
Read-only root filesystem — the biggest win:
|
|
ProtectSystem=strict makes /usr, /etc, and /lib read-only for
the service. The process can still write to /var, /home, /root,
/tmp, and any explicitly permitted paths.
For even tighter lockdown:
|
|
ProtectSystem=full makes /usr, /etc, /lib, and /var read-only.
Typical for stateless services that only need to write to specific
directories.
Allow specific writable paths:
|
|
The service can only write to those two directories. Everything else is read-only.
Hide /home, /root, and /run/user:
|
|
ProtectHome=true makes /home, /root, and /run/user
inaccessible. The process inside the unit literally cannot see them.
Most services never need to read user home directories.
Make /tmp and /var/tmp private:
|
|
Each service gets its own private /tmp and /var/tmp that no other
process can see. This prevents a service A from reading temporary files
created by service B — a common attack vector for race conditions and
symlink attacks.
Hardening Level 2: Capability Dropping
Like Docker, systemd supports Linux capability management. You define exactly which capabilities the service binary is allowed to use.
Drop all capabilities, add back only what’s needed:
|
|
The ~ prefix inverts the match: “drop these capabilities.” For most
services, you can drop everything:
|
|
Empty CapabilityBoundingSet means no capabilities — the service
cannot:
- Change file permissions (
CAP_DAC_OVERRIDE) - Bind to privileged ports (
CAP_NET_BIND_SERVICE— ports under 1024) - Mount filesystems (
CAP_SYS_ADMIN) - Access kernel features (
CAP_SYS_MODULE,CAP_SYS_PTRACE)
If your service needs to bind to port 80 or 443:
|
|
The AmbientCapabilities is required for the capability to survive
exec() calls. Without it, the cap is set in the bounding set but
not inherited by the child process.
Set NoNewPrivileges — the single most important flag:
|
|
This prevents the service from gaining new privileges via suid binaries
or capset syscalls. Even if an attacker gets code execution, they
cannot escalate. This is the equivalent of Docker’s
security_opt: no-new-privileges:true.
Hardening Level 3: Process and Network Isolation
systemd can hide the rest of the system from the service entirely.
Hide everything except the service’s own process tree:
|
|
ProtectProc=invisible means /proc only shows the service’s own
processes — no other processes on the system are visible.
ProcSubset=pid restricts /proc to only process directories. All
the kernel tuning files in /proc/sys, /proc/net, and /proc/fs are
hidden.
Deny all network access:
|
|
The service gets a loopback-only network with 127.0.0.1. No external
connections in or out. Use this for services that don’t need the
network at all (local cron tasks, maintenance scripts, log processors).
Restrict to specific network namespaces or sockets —
systemd v250+ supports socket-activation with network namespace
isolation via NetworkNamespacePath=, but for most homelabs,
PrivateNetwork=true is the practical option.
Hide other runtime information:
|
|
Hides all physical devices from the service. Only null, zero,
full, random, urandom are available. The service cannot access
disk devices, USB devices, or GPU devices.
|
|
Prevents the service from modifying kernel parameters via
/proc/sys or /sys.
|
|
Blocks insmod and modprobe — the service cannot load kernel
modules.
|
|
Prevents the service from reading kernel log messages via dmesg or
/proc/kmsg.
|
|
Blocks changes to the system clock. Critical for services that sync time or use timestamps — an attacker can’t corrupt your logs by changing the clock.
Hardening Level 4: Resource Limits
A compromised service shouldn’t be able to DoS the rest of the host. systemd supports cgroups v2 resource control natively.
CPU and memory limits:
|
|
MemoryMax: hard memory limit (OOM kill if exceeded)MemoryHigh: soft throttle limit (starts reclaiming above this)MemorySwapMax=0: no swap usage — on low-memory homelab servers, prevents the service from pushing the system into swapCPUQuota=50%: limit to 50% of one CPU coreTasksMax=100: max number of tasks (processes + threads) — prevents fork bombs
Restart and failure handling:
|
|
The service restarts after a crash, but only three times in 60 seconds. After that, systemd gives up and marks the unit as failed — prevents a crash loop from burning CPU cycles forever.
OOM killer adjustment:
|
|
When the kernel’s OOM killer fires, continue means the service keeps
running (systemd won’t kill it). Use stop if you want systemd to
stop the unit on OOM (cleaner than the kernel blindly killing the
process).
Hardening Level 5: User and Group Separation
Never run services as root. systemd makes user separation trivial.
Run as a dedicated system user:
|
|
DynamicUser=true is a killer feature — systemd creates a transient
user and group with no login shell, no home directory, and deletes
them when the service stops. No /etc/passwd pollution.
With DynamicUser, StateDirectory and friends:
|
|
systemd creates the directories under /var/lib/,
/var/cache/, /var/log/, and /run/ respectively, sets the right
ownership (to the dynamic user), and cleans them up if configured.
For services that need a persistent user:
|
|
Create the user manually:
|
|
The -r makes it a system user (UID < 1000), -s /usr/sbin/nologin
disables login, and -M skips creating a home directory.
Supplementary groups only when needed:
|
|
For services that need socket access (e.g., a monitoring script that reads the Docker socket), add only the specific group instead of running as root.
The Complete Hardened Service Template
Combining every directive into a single production-ready template:
|
|
This template assumes the service doesn’t need the network
(PrivateNetwork=true), doesn’t need capabilities, and doesn’t need
to write to most of the filesystem. Adjust for your service’s actual
requirements — most services will need some of these relaxed.
Applying Hardening to Existing Services
Don’t modify the base unit file for packaged services — use drop-in overrides.
Create an override for Nginx:
|
|
This opens /etc/systemd/system/nginx.service.d/override.conf. Add:
|
|
Apply and verify:
|
|
Check if hardening broke anything:
|
|
If Nginx can’t read SSL certificates, add the cert path to ReadWritePaths or verify the cert directory permissions.
Creating a Fully Isolated Service: Example
Let’s build a hardened service for a custom health check script (like the homelab health receipt that was trending on Reddit).
The script — /usr/local/bin/health-report.sh:
|
|
The hardened unit — /etc/systemd/system/health-report.service:
|
|
The timer — /etc/systemd/system/health-report.timer:
|
|
Enable both:
|
|
The report script runs once daily as a dynamic user, with no root access, no home directory visibility, no kernel access, and a strict 64MB memory cap. If an attacker exploits the script, they’re trapped in a sandbox with no capabilities and no network access beyond localhost.
Verifying Your Hardening
After applying changes, verify each directive is active:
|
|
This outputs a security score (EXPOSURE level from 1-10) and a table
of every sandboxing directive with checkmarks. Aim for SAFE or
EXPOSURE=1-2 for services that don’t need network or device access.
|
|
|
|
|
|
Common Issues and Fixes
“Permission denied” on startup: Your service needs to write to a
directory you didn’t allow. Use ReadWritePaths= to whitelist it, or
use StateDirectory= which systemd creates with correct ownership.
“Can’t bind to port 80”: The service needs CAP_NET_BIND_SERVICE.
Add CapabilityBoundingSet=CAP_NET_BIND_SERVICE and
AmbientCapabilities=CAP_NET_BIND_SERVICE. Or run on a port >1024
and set up a reverse proxy.
“Can’t connect to database on another host”: PrivateNetwork=true
blocks all network. Remove it (the default is false). For tighter
security, use RestrictAddressFamilies=AF_INET AF_INET6 instead.
Service starts as the right user but can’t read config:
Filesystem permissions matter. Store configs in a directory owned by
the service user, or use LoadCredential= to pass secrets securely:
|
|
SystemCallFilter breaks the service: Some apps (Java, Node.js running native modules, Chrome/Puppeteer) use uncommon syscalls. Check what’s being blocked:
|
|
Use -1-SystemCallFilter= (drop specific syscalls) instead of
+1@system-service (allow a preset). Or drop SystemCallFilter
entirely if the service legitimately needs exotic syscalls.
Summary: The Minimal Hardening Checklist
These eight directives cover 95% of the improvement with minimal breakage. Apply them to every unit you create:
|
|
Run systemd-analyze security my-service after every change to see
your exposure score drop. When a service scores SAFE on all
directives, you’ve done your job.
systemd hardening is the equivalent of Docker’s security options — it’s built into the init system, it costs zero additional tooling, and it relies on the same kernel primitives (cgroups, namespaces, seccomp, capabilities). Use it.