DevOps & SRE No. 169

systemd Services skill for Claude Code

Units that restart sanely and run sandboxed. Claude writes units with the right Type=, foreground ExecStart, restart backoff and start limits, and puts changes in drop-ins instead of vendor files. It replaces cron and nohup with timers and services, keeps secrets in credentials rather than unit files, and hardens each service directive by directive until systemd-analyze security reports a low exposure score.

  • namesystemd-services
  • size264 lines · ≈2,450 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Runs long-lived services and scheduled jobs under systemd - unit file structure, Type=simple/exec/notify/oneshot, ExecStart without shell tricks, Restart= policies with start-limit and backoff, environment and credentials, drop-in overrides, journald logging and retention, timers replacing cron, socket activation, user services, and sandboxing with ProtectSystem, DynamicUser, CapabilityBoundingSet and systemd-analyze security. Use when writing or debugging a .service or .timer unit, when a service will not start or keeps restarting, when moving a cron job or nohup process to systemd, or when hardening a unit.

Try asking

  • Turn this nohup node server.js setup into a proper systemd service
  • My service says start request repeated too quickly. Figure out what is wrong
  • Harden this systemd unit and get its systemd-analyze security score down

Use it when

  • Writing a .service, .timer or .socket unit
  • A unit fails to start, crash-loops, or stops slowly
  • Replacing cron entries or background processes with systemd
  • Sandboxing a service with ProtectSystem, DynamicUser and syscall filters

Skip it when

How to check the result

systemd-analyze verify passes, the security exposure score is in the OK range, the service restarts after a forced kill, and it is active after a reboot with clean journal output.

The common mistake it prevents

Using After=network.target alone and a shell-style ExecStart with cd and &&, so the service starts before the network is usable and fails in ways that never happen by hand.

~/.claude/skills/systemd-services/SKILL.md

--- name: systemd-services description: Runs long-lived services and scheduled jobs under systemd - unit file structure, Type=simple/exec/notify/oneshot, ExecStart without shell… license: MIT ---

A unit is done when it starts from a clean boot in the right order, restarts on failure with backoff instead of hammering, logs to the journal, runs as an unprivileged user with a sandbox score from systemd-analyze security in the “OK” range or better, and every change goes in a drop-in or a versioned unit file, not an ad-hoc edit.

Ground rules

  • Put custom units in /etc/systemd/system/. Never edit files under /usr/lib/systemd/system/ or /lib/systemd/system/; use systemctl edit <unit> (creates a drop-in under /etc/systemd/system/<unit>.d/).
  • Run systemctl daemon-reload after every unit file change.
  • No sudo, su, nohup, &, or bash -c "cd x && ..." in ExecStart. Use User=, WorkingDirectory=, Environment=; the process must stay in the foreground.
  • Restarting or stopping a production service drops connections; confirm with the user before systemctl restart on a live host, and prefer reload when supported.
  • Secrets never go in Environment= lines of world-readable unit files; use LoadCredential=/SetCredentialEncrypted= or an EnvironmentFile= with mode 0600.

Workflow

- [ ] 1. Inspect what exists: unit, status, logs, dependencies
- [ ] 2. Choose Type= and the restart policy
- [ ] 3. Write the unit (or drop-in) with user, env, logging
- [ ] 4. Add hardening; check the exposure score
- [ ] 5. Load, enable, start; read logs
- [ ] 6. Prove restart, reboot, and failure behavior

1. Inspect

systemctl status myapp.service               # state, main PID, last log lines
systemctl cat myapp.service                  # effective unit including drop-ins
systemctl show myapp.service -p Restart,NRestarts,ExecMainStatus,Result,User
journalctl -u myapp.service -b --no-pager -n 200
journalctl -u myapp.service -p err --since "1 hour ago"
systemctl list-dependencies myapp.service
systemd-analyze verify /etc/systemd/system/myapp.service
systemctl --failed

2. Type and restart decisions

Process behaviorType=
Runs in foreground, ready immediatelyexec (reports exec failures correctly; prefer over simple)
Supports sd_notify (READY=1)notify (accurate readiness for dependents)
Runs to completion (migrations, backups, timer jobs)oneshot (+ RemainAfterExit=yes if it sets up state)
Classic daemon that forksforking with PIDFile=; better: run it in foreground mode
SituationRestart policy
Long-running serverRestart=on-failure, RestartSec=2s
Must always run, even after clean exitRestart=always
Oneshot jobNo restart; let the timer retry, alert on failure
Crash loops must not hammer dependenciesRestartSteps= + RestartMaxDelaySec= (systemd 254+) for exponential backoff

Start limits live in [Unit]: StartLimitIntervalSec=300 and StartLimitBurst=5. After that the unit enters failed state; systemctl reset-failed myapp clears it.

3. Service template

# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp HTTP API
Documentation=https://git.acme.dev/myapp
Wants=network-online.target
After=network-online.target postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=exec
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
# env file: mode 0640 root:myapp
EnvironmentFile=/etc/myapp/env
Environment=PORT=8080 NODE_ENV=production
ExecStartPre=/opt/myapp/bin/check-config
ExecStart=/usr/bin/node /opt/myapp/server.js
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2s
RestartSteps=5
RestartMaxDelaySec=60s
TimeoutStartSec=30s
TimeoutStopSec=30s
KillSignal=SIGTERM
LimitNOFILE=65536
# creates /var/lib/myapp owned by User=
StateDirectory=myapp
LogsDirectory=myapp
RuntimeDirectory=myapp

# hardening baseline (see step 4)
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
# MemoryDenyWriteExecute=yes breaks JITs (Node, Java, .NET); enable for non-JIT binaries
SystemCallFilter=@system-service
SystemCallArchitectures=native
CapabilityBoundingSet=

[Install]
WantedBy=multi-user.target

Need port 80/443 without root: AmbientCapabilities=CAP_NET_BIND_SERVICE and CapabilityBoundingSet=CAP_NET_BIND_SERVICE, or bind high and proxy.

Create the user: useradd --system --no-create-home --shell /usr/sbin/nologin myapp, or use DynamicUser=yes for stateless services (combine with StateDirectory= for persistent data).

Credentials (systemd 247+): LoadCredential=dbpass:/etc/myapp/secrets/dbpass exposes the file at $CREDENTIALS_DIRECTORY/dbpass, readable only by the service. Encrypted: systemd-creds encrypt --name=dbpass plain.txt /etc/credstore.encrypted/dbpass then LoadCredentialEncrypted=dbpass.

4. Hardening

systemd-analyze security myapp.service       # exposure score 0-10 and per-directive findings

Target: score below ~4.0 (“OK”) for network services. Add directives one at a time, restart, and exercise the app; read references/hardening.md for what each directive blocks and which ones break common runtimes. If the app writes outside its State/Logs/Runtime directories under ProtectSystem=strict, add ReadWritePaths=/specific/path, not ProtectSystem=full.

5. Load and start

sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
systemctl is-active myapp && systemctl is-enabled myapp
journalctl -u myapp -f

Drop-in override (changes survive package upgrades):

sudo systemctl edit myapp.service
# [Service]
# Environment=LOG_LEVEL=debug
# ExecStart=                      <- empty line clears the list before redefining
# ExecStart=/usr/bin/node /opt/myapp/server.js --inspect=127.0.0.1:9229

Timers (replacing cron)

# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
[Service]
Type=oneshot
User=backup
ExecStart=/opt/backup/run.sh
Nice=10
IOSchedulingClass=idle

# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly backup timer
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=15m
# run at boot if the last scheduled run was missed
Persistent=true
AccuracySec=1m
[Install]
WantedBy=timers.target
systemd-analyze calendar "*-*-* 02:30:00"     # validate expression, show next run
sudo systemctl enable --now backup.timer
systemctl list-timers --all
sudo systemctl start backup.service           # run once now to test

Alert on failures with OnFailure=notify-failure@%n.service in the service [Unit]. Timers do not overlap: a oneshot still running blocks the next activation. For cross-platform scheduling concerns (locking, missed-run monitoring) see scheduled-jobs.

journald

  • Logs: write to stdout/stderr; journald captures them with metadata. Do not log to files and also stdout.
  • Retention: /etc/systemd/journald.conf -> SystemMaxUse=2G, MaxRetentionSec=30day; persistent storage requires /var/log/journal (or Storage=persistent).
  • Rate limiting can drop bursts: LogRateLimitIntervalSec=/LogRateLimitBurst= per unit.
  • Useful: journalctl -u myapp -o json-pretty, journalctl --disk-usage, journalctl -u myapp --since today --grep "timeout".

User services

~/.config/systemd/user/x.service, managed with systemctl --user. They stop at logout unless loginctl enable-linger <user>. Use WantedBy=default.target.

Debugging

SymptomCheck
status=203/EXECBinary path wrong, not executable, missing shebang, or noexec mount
status=217/USERUser= does not exist
status=200/CHDIRWorkingDirectory missing or not accessible to the user
status=226/NAMESPACESandboxing path missing (ReadWritePaths to nonexistent dir)
Works by hand, fails as unitDifferent env/PATH/HOME; run systemd-run --pty --uid=myapp --property=... cmd to reproduce
“start request repeated too quickly”Start limit hit: fix cause, then systemctl reset-failed
Dependents start before app is readyUse Type=notify or a readiness ExecStartPost= check
Stop takes 90 sApp ignores SIGTERM; fix signal handling or set KillSignal=/TimeoutStopSec=
Starts before network is usableAfter= and Wants=network-online.target (both)

Gotchas

  • After= orders but does not pull in; Wants=/Requires= pull in but do not order. You usually need both.
  • Environment= values with spaces need quotes per assignment: Environment="JAVA_OPTS=-Xms512m -Xmx512m".
  • systemd does not support trailing comments: Key=value # note makes # note part of the value. Put comments on their own line.
  • ExecStart does not use a shell: no pipes, globs, ~, or $(...). Variables expand only as ${VAR}/$VAR from the unit environment.
  • Restart=always with a config error loops until the start limit; keep ExecStartPre validation so failures are fast and visible.
  • PrivateTmp=yes means /tmp files are not shared with other processes; sockets in /tmp move to RuntimeDirectory=.
  • Persistent=true on a timer with a long downtime fires immediately on boot; make jobs idempotent.

Verification

systemd-analyze verify /etc/systemd/system/myapp.service
systemd-analyze security myapp.service | tail -1
sudo systemctl kill -s SIGKILL myapp && sleep 5 && systemctl is-active myapp   # restarts? (non-prod or confirmed)
systemctl show myapp -p NRestarts

Then reboot a staging host (or confirm with the user for production) and check systemctl is-active myapp and journalctl -u myapp -b.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/hardening.md 88 lines · 5.0 KB
# systemd hardening directives

Read when tuning a unit's sandbox after `systemd-analyze security` flags issues, or when
a hardened unit fails with `226/NAMESPACE`, permission denied, or `SIGSYS`.

Apply in this order, restarting and exercising the app after each group. Check
`journalctl -u <unit>` and `dmesg | grep -i audit` for denials.

## Group 1: almost always safe

| Directive | Effect | Breaks |
|---|---|---|
| `NoNewPrivileges=yes` | setuid binaries and file caps cannot raise privileges | Apps that exec sudo/ping with setuid |
| `PrivateTmp=yes` | Private /tmp and /var/tmp | Sharing files via /tmp with other services |
| `ProtectHome=yes` | /home, /root, /run/user invisible | Apps reading config from home dirs (use `read-only` instead) |
| `ProtectKernelTunables=yes` | /proc/sys, /sys read-only | Apps tuning sysctls at runtime |
| `ProtectKernelModules=yes` | No module loading | Nothing in normal apps |
| `ProtectKernelLogs=yes` | No kernel log access | Log shippers reading kmsg |
| `ProtectControlGroups=yes` | cgroup fs read-only | Container runtimes |
| `ProtectClock=yes` | Cannot set the clock | NTP daemons |
| `ProtectHostname=yes` | Cannot change hostname | Nothing in normal apps |
| `LockPersonality=yes` | Locks execution domain | Rare legacy binaries |
| `RestrictRealtime=yes` | No realtime scheduling | Audio/low-latency apps |
| `RestrictSUIDSGID=yes` | Cannot create setuid files | Package managers |
| `SystemCallArchitectures=native` | Blocks non-native syscall ABIs | 32-bit binaries on 64-bit hosts |

## Group 2: filesystem

| Directive | Effect | Notes |
|---|---|---|
| `ProtectSystem=strict` | Entire fs read-only except /dev, /proc, /sys | Pair with `StateDirectory=`, `LogsDirectory=`, `CacheDirectory=`, `RuntimeDirectory=`, or `ReadWritePaths=` |
| `ProtectSystem=full` | /usr, /boot, /etc read-only | Weaker fallback |
| `ReadWritePaths=/srv/uploads` | Punch writable holes | Path must exist or start fails (226) unless prefixed with `-` |
| `ReadOnlyPaths=` / `InaccessiblePaths=` | Further restriction | `InaccessiblePaths=/etc/ssh` for defense in depth |
| `PrivateDevices=yes` | Only pseudo devices (null, zero, random) | Breaks GPU, serial, FUSE access |
| `TemporaryFileSystem=/var:ro` + `BindPaths=` | Build a minimal view | For high-value services |
| `UMask=0027` | Default file perms | Files group-readable, not world-readable |

## Group 3: identity and capabilities

| Directive | Effect | Notes |
|---|---|---|
| `User=`/`Group=` | Run unprivileged | Create a system user |
| `DynamicUser=yes` | Ephemeral UID allocated at start | Implies ProtectSystem=strict, PrivateTmp; persistent data via `StateDirectory=` |
| `CapabilityBoundingSet=` (empty) | Drop all capabilities | List only what is needed, e.g. `CAP_NET_BIND_SERVICE` |
| `AmbientCapabilities=CAP_NET_BIND_SERVICE` | Grant a cap to non-root process | Needed to bind <1024 without root |
| `PrivateUsers=yes` | User namespace; host users unmapped | Breaks access to files owned by other host users |

## Group 4: network

| Directive | Effect | Notes |
|---|---|---|
| `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX` | Limit socket families | Add `AF_NETLINK` if the app enumerates interfaces |
| `PrivateNetwork=yes` | Only loopback | For batch jobs with no network needs |
| `IPAddressDeny=any` + `IPAddressAllow=10.0.0.0/8 127.0.0.0/8` | eBPF IP firewall per unit | Requires cgroup v2 |
| `SocketBindAllow=tcp:8080` + `SocketBindDeny=any` | Restrict which ports the unit may bind | Newer systemd versions |

## Group 5: syscalls and memory

| Directive | Effect | Breaks |
|---|---|---|
| `SystemCallFilter=@system-service` | Allowlist of typical service syscalls | Rare; add groups as needed |
| `SystemCallFilter=~@privileged @resources` | Deny groups | Apps adjusting priorities/rlimits |
| `SystemCallErrorNumber=EPERM` | Return EPERM instead of killing with SIGSYS | Makes failures debuggable |
| `MemoryDenyWriteExecute=yes` | No W+X memory | JIT runtimes: Node.js, JVM, .NET, LuaJIT, PCRE JIT |
| `RestrictNamespaces=yes` | Cannot create namespaces | Container tools, Chromium sandbox |

List groups and members with `systemd-analyze syscall-filter @system-service`.

## Resource controls (not security score, but stability)

| Directive | Use |
|---|---|
| `MemoryHigh=` / `MemoryMax=` | Throttle then OOM-kill the unit only, not the host |
| `CPUQuota=200%` | Cap at 2 CPUs |
| `TasksMax=512` | Stop fork bombs and thread leaks |
| `IOWeight=` / `IOSchedulingClass=idle` | Deprioritize batch jobs |
| `OOMScoreAdjust=` | Influence kernel OOM victim choice |

## Debugging a broken sandbox

1. `systemctl status` exit code: 226 = namespace setup failed (missing path), 31/SIGSYS =
   syscall filter, 203 = exec.
2. Temporarily comment out the most recent group, `daemon-reload`, restart.
3. For syscall filters, set `SystemCallErrorNumber=EPERM` and run with
   `strace -f -e trace=all` via `systemd-run` to find the blocked call, or check
   `journalctl -k` for `audit: type=1326` lines naming the syscall number.
4. Re-run `systemd-analyze security <unit>` and record the score in the PR.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc