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.
- name
systemd-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
- The workload runs in Kubernetes — use kubernetes-deploy
- You are configuring many hosts declaratively — use ansible-playbooks
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.
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/; usesystemctl edit <unit>(creates a drop-in under/etc/systemd/system/<unit>.d/). - Run
systemctl daemon-reloadafter every unit file change. - No
sudo,su,nohup,&, orbash -c "cd x && ..."inExecStart. UseUser=,WorkingDirectory=,Environment=; the process must stay in the foreground. - Restarting or stopping a production service drops connections; confirm with the user
before
systemctl restarton a live host, and preferreloadwhen supported. - Secrets never go in
Environment=lines of world-readable unit files; useLoadCredential=/SetCredentialEncrypted=or anEnvironmentFile=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 behavior | Type= |
|---|---|
| Runs in foreground, ready immediately | exec (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 forks | forking with PIDFile=; better: run it in foreground mode |
| Situation | Restart policy |
|---|---|
| Long-running server | Restart=on-failure, RestartSec=2s |
| Must always run, even after clean exit | Restart=always |
| Oneshot job | No restart; let the timer retry, alert on failure |
| Crash loops must not hammer dependencies | RestartSteps= + 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(orStorage=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
| Symptom | Check |
|---|---|
status=203/EXEC | Binary path wrong, not executable, missing shebang, or noexec mount |
status=217/USER | User= does not exist |
status=200/CHDIR | WorkingDirectory missing or not accessible to the user |
status=226/NAMESPACE | Sandboxing path missing (ReadWritePaths to nonexistent dir) |
| Works by hand, fails as unit | Different 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 ready | Use Type=notify or a readiness ExecStartPost= check |
| Stop takes 90 s | App ignores SIGTERM; fix signal handling or set KillSignal=/TimeoutStopSec= |
| Starts before network is usable | After= 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 # notemakes# notepart of the value. Put comments on their own line. ExecStartdoes not use a shell: no pipes, globs,~, or$(...). Variables expand only as${VAR}/$VARfrom the unit environment.Restart=alwayswith a config error loops until the start limit; keepExecStartPrevalidation so failures are fast and visible.PrivateTmp=yesmeans/tmpfiles are not shared with other processes; sockets in/tmpmove toRuntimeDirectory=.Persistent=trueon 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
# 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.