What Happened at 2 AM
I once had my server brute-forced over SSH and had to scramble to deal with it in the middle of the night. Watching the logs and seeing hundreds of failed login attempts from an unfamiliar IP — not a fun feeling. From that day on, I completely changed how I set up servers: security from the ground up, not waiting for an incident to force my hand.
One of the things I started doing after that incident was systemd hardening — specifically the ProtectSystem, ProtectHome, and PrivateTmp directives. Most sysadmins skip these because they either don’t know about them or think they’re complicated. In reality, setup takes 10 minutes, and the benefits are immediately obvious.
The core problem is this: when a service gets compromised, an attacker can read /etc/passwd, write to home directories, or leak data through /tmp to other services. systemd hardening blocks that at the kernel level — no additional software required.
Three Directives to Understand Before Configuring
ProtectSystem — Locking the System Filesystem
Restricts write access to the filesystem. Three increasing levels:
true— Mounts/usrand/bootread-onlyfull— Adds/etcto the read-only liststrict— Entire filesystem is read-only, except paths explicitly whitelisted viaReadWritePaths
I typically use strict for services that don’t need to write outside their own data directories.
ProtectHome — Hiding Home Directories
Hides or empties the home directories of all users. Three levels:
true—/home,/root, and/run/userbecome inaccessibleread-only— Mounted in read-only modetmpfs— An empty tmpfs is mounted over them; the service sees completely empty home directories
PrivateTmp — Isolated /tmp Namespace
Provides a separate /tmp and /var/tmp namespace for each service. This matters more than you might think: many applications use /tmp to exchange temporary data. If a service gets compromised and writes files to /tmp, without PrivateTmp another service on the same server can read those files.
Setup and Configuration
Nothing extra to install — systemd is built into Ubuntu, Debian, and RHEL. Just edit the service file.
Modifying an Existing Service with an Override File
Don’t edit the original service file directly — package updates will overwrite it. Use systemctl edit instead:
sudo systemctl edit nginx
This opens an editor and creates a file at /etc/systemd/system/nginx.service.d/override.conf. Add the following:
[Service]
# Filesystem protection
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# Nginx needs write access to these paths — must whitelist
ReadWritePaths=/var/log/nginx /var/lib/nginx /run/nginx
After saving, reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart nginx
sudo systemctl status nginx
Writing a New Service File with Hardening from the Start
If you’re creating a service file for your own application (Python, Node.js, etc.), add these directly to the [Service] section:
[Unit]
Description=My Web App
After=network.target
[Service]
Type=simple
User=webapp
Group=webapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/venv/bin/python app.py
# --- Hardening ---
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
# Only allow writes to the app's data directories
ReadWritePaths=/opt/myapp/data /var/log/myapp
[Install]
WantedBy=multi-user.target
Additional Directives Worth Using
Beyond the three main ones, I add these to every service:
NoNewPrivileges=true— Service cannot escalate privileges via setuid/setgidProtectKernelTunables=true— Blocks writes to/proc/sysand/sysProtectKernelModules=true— Prevents loading arbitrary kernel modulesProtectControlGroups=true—/sys/fs/cgroupbecomes read-onlyRestrictSUIDSGID=true— Prevents creation of new SUID/SGID filesRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6— Limits the socket types the service can use
Verification and Monitoring
Verify the Configuration Is Applied
After restarting, confirm the directives are in effect:
systemctl show nginx | grep -E "ProtectSystem|ProtectHome|PrivateTmp"
Expected output:
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
Use systemd-analyze to Evaluate the Security Score
This is a built-in tool that most people don’t know about. It scores each service’s security posture (0 = best, 10 = worst):
systemd-analyze security nginx
The output shows a detailed breakdown of each directive and an overall score:
NAME DESCRIPTION EXPOSURE
✓ PrivateTmp=yes Service has a private /tmp 0.0
✓ ProtectSystem=strict Protected system files 0.0
✓ NoNewPrivileges=yes Cannot gain new privileges 0.0
✗ User=/DynamicUser= Service runs as root user 0.4
...
→ Overall exposure level for nginx.service: 3.8 OK 🙂
The goal is to get the score below 4.0. With just the three main directives, scores typically drop from 7–8 down to 4–5.
Live Testing — Confirming Protection Works
The fastest way to verify ProtectHome is working: enter the process’s namespace and inspect the home directory:
# Get the nginx PID
PID=$(systemctl show nginx --property MainPID --value)
# Check /home in nginx's namespace
sudo nsenter -t $PID --mount -- ls -la /home/
# If ProtectHome=true → directory is completely empty
# Check isolated /tmp
sudo nsenter -t $PID --mount -- ls /tmp/
# If PrivateTmp=true → /tmp is clean, no files from other processes visible
Watch for Errors After Enabling Hardening
This step is the most important — a missing ReadWritePaths entry is the most common reason services crash after enabling ProtectSystem=strict:
sudo journalctl -u nginx -f
Typical error:
nginx: [emerg] open() "/var/log/nginx/error.log" failed (30: Read-only file system)
Fix it by adding the path to the whitelist in the override file:
sudo systemctl edit nginx
# Add line: ReadWritePaths=/var/log/nginx
sudo systemctl daemon-reload && sudo systemctl restart nginx
Quick Audit Script for All Services
Want to know which services on your server haven’t been hardened yet:
#!/bin/bash
echo "Services with security score > 6.0 (need hardening):"
for svc in $(systemctl list-units --type=service --state=running --no-legend | awk '{print $1}'); do
score=$(systemd-analyze security "$svc" 2>/dev/null | grep "Overall exposure" | grep -oP '[0-9]+\.[0-9]+')
if [[ -n "$score" ]] && (( $(echo "$score > 6.0" | bc -l) )); then
echo " $svc → score: $score"
fi
done
Prioritization Order When Applying
Not every service needs the same level of hardening. Here’s how I prioritize:
- Internet-facing services (nginx, apache, Node.js web apps) — apply immediately at the
strictlevel - Services handling sensitive data (databases, auth services) —
strictplusPrivateNetworkif they only need localhost - Background jobs and cron tasks — at minimum
ProtectSystem=full+PrivateTmp=true - Core system services (sshd, systemd-resolved) — proceed carefully, test thoroughly in a staging environment first
The lesson I took away from the SSH brute-force incident: attackers don’t need root from the start. They just need to get into one service, read a config file containing a database password, and pivot to another target. ProtectSystem=strict blocks exactly that step — even if a service is compromised, it can’t read /etc/otherapp/db.conf belonging to a neighboring service.
Setup takes 10 minutes, auditing with systemd-analyze security takes another 5. The payoff is sleeping soundly at 2 AM.
