Skip to content
Linux Administration
Lab 20 of 27·45mIntermediate

Ship your app as a hardened systemd service

Take a script from "it runs in my terminal" to a unit with its own account, its own state directory, and a sandbox that survives a compromise.

You need

  • A Linux VM with systemd 247+ and sudo access

Do first

Day 07 got a service running. This is the version you would defend in a review: least privilege, no writable paths it does not need, and a read-only filesystem everywhere else.

1. The application, and a dedicated account

sudo mkdir -p /opt/metricsd
sudo tee /opt/metricsd/metricsd.sh >/dev/null <<'SCRIPT'
#!/bin/bash
set -euo pipefail
STATE="${STATE_DIRECTORY:?STATE_DIRECTORY not set}"
while true; do
  printf '%s cpu_queue=%s\n' "$(date -Is)" "$(awk '{print $1}' /proc/loadavg)" \
    >> "$STATE/metrics.log"
  sleep 5
done
SCRIPT
sudo chmod 755 /opt/metricsd/metricsd.sh
sudo useradd --system --no-create-home --shell /usr/sbin/nologin metricsd

Note the script reads STATE_DIRECTORY from the environment rather than hardcoding a path. systemd sets that variable, which means the unit owns the decision about where state lives.

Verify

getent passwd metricsd | cut -d: -f3,7 # a uid below 1000, and /usr/sbin/nologin

2. A unit with the sandbox on

sudo tee /etc/systemd/system/metricsd.service >/dev/null <<'UNIT'
[Unit]
Description=Metrics collector
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/opt/metricsd/metricsd.sh
User=metricsd
Group=metricsd

# systemd creates /var/lib/metricsd, owns it to User=, and exports
# STATE_DIRECTORY. Removes the "who mkdirs this" question entirely.
StateDirectory=metricsd

Restart=on-failure
RestartSec=5

# Sandboxing. Each line removes something the service does not need.
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now metricsd
systemctl status metricsd --no-pager | head -n 6

ProtectSystem=strict mounts the entire filesystem read-only except /dev, /proc, /sys — and whatever StateDirectory created. That combination is the important one: the service can write exactly one place.

Verify

systemctl is-active metricsd; sudo tail -n 1 /var/lib/metricsd/metrics.log # active, then one recent metrics line

3. Prove the sandbox is real

sudo systemctl stop metricsd
sudo systemd-run --uid=metricsd --property=ProtectSystem=strict \
  --property=ProtectHome=true --pty /bin/bash -c 'touch /etc/evil; echo "exit=$?"'

The write to /etc fails inside the same sandbox the service runs in. Compare without it:

sudo systemd-run --uid=metricsd --pty \
  /bin/bash -c 'touch /tmp/harmless; echo "exit=$?"'

The second succeeds because nothing is protecting /tmp in that invocation — which is what PrivateTmp=true fixes in the real unit, by giving the service its own /tmp that vanishes on stop.

Verify

sudo systemd-run --uid=metricsd --property=ProtectSystem=strict --pty \ /bin/bash -c 'touch /etc/evil 2>&1 | tail -n 1' # touch: cannot touch '/etc/evil': Read-only file system

4. Score it

sudo systemctl start metricsd
systemd-analyze security metricsd | tail -n 20
systemd-analyze security metricsd | grep -i "overall exposure"

systemd-analyze security grades a unit and lists what is still open. Do not chase a perfect score — read the list and remove what your service genuinely does not need. A collector that only reads /proc and writes one directory should land in the low exposure range.

Verify

systemd-analyze security metricsd | grep -io "exposure level for metricsd.service is [0-9.]*" # a number well below 9 — an unsandboxed unit scores above 9

5. Give it a resource ceiling

sudo mkdir -p /etc/systemd/system/metricsd.service.d
sudo tee /etc/systemd/system/metricsd.service.d/limits.conf >/dev/null <<'DROPIN'
[Service]
MemoryMax=128M
CPUQuota=20%
TasksMax=32
DROPIN
sudo systemctl daemon-reload
sudo systemctl restart metricsd
systemctl show metricsd -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax

These are cgroup limits, enforced by the kernel. MemoryMax means the service gets OOM-killed instead of taking the machine down with it — the difference between one service failing and every service failing.

Verify

systemctl show metricsd -p MemoryMax --value # 134217728

Clean up

sudo systemctl disable --now metricsd
sudo rm -rf /etc/systemd/system/metricsd.service \
  /etc/systemd/system/metricsd.service.d
sudo rm -rf /opt/metricsd /var/lib/metricsd
sudo userdel metricsd
sudo systemctl daemon-reload

Where this goes next

Your service is contained. Next: containing who can reach the machine at all.