Systemd Hardening: Securing Services with ProtectSystem, ProtectHome, and PrivateTmp

Security tutorial - IT technology blog
Security tutorial - IT technology blog

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 /usr and /boot read-only
  • full — Adds /etc to the read-only list
  • strict — Entire filesystem is read-only, except paths explicitly whitelisted via ReadWritePaths

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/user become inaccessible
  • read-only — Mounted in read-only mode
  • tmpfs — 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/setgid
  • ProtectKernelTunables=true — Blocks writes to /proc/sys and /sys
  • ProtectKernelModules=true — Prevents loading arbitrary kernel modules
  • ProtectControlGroups=true/sys/fs/cgroup becomes read-only
  • RestrictSUIDSGID=true — Prevents creation of new SUID/SGID files
  • RestrictAddressFamilies=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:

  1. Internet-facing services (nginx, apache, Node.js web apps) — apply immediately at the strict level
  2. Services handling sensitive data (databases, auth services) — strict plus PrivateNetwork if they only need localhost
  3. Background jobs and cron tasks — at minimum ProtectSystem=full + PrivateTmp=true
  4. 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.

Share: