Servers Security

Mastering systemd: Units, Timers, Resource Control, and Service Hardening

Most engineers touch systemd through three commands — systemctl start, stop, and status — and never open the unit file underneath. That is the most expensive habit in Linux operations, because a unit file is not boilerplate: it is a declarative contract that hands you a process supervisor, a dependency resolver, a cron replacement, a cgroup-based resource controller, and a syscall-filtering sandbox, all from one INI file with no wrapper scripts and no external daemon. The difference between a service that crash-loops silently at 3am and one that trips a watchdog, sheds memory under pressure, and pages you with a clean journal is usually fifteen lines of [Service] config that the person who wrote it never learned existed.

This is the deep dive. We walk every unit type (service, socket, timer, target, mount, path, slice, scope), the full anatomy of the [Unit]/[Service]/[Install] sections, all five service Type= values and exactly when each is correct, the orthogonal-and-constantly-confused pair of dependency (Wants=/Requires=) versus ordering (After=/Before=), timers as a strictly better cron, cgroup v2 resource control (CPU/Memory/IO/Tasks accounting and hard/soft limits, organised into slices), the sandboxing directives that replace a pile of AppArmor/SELinux work for common cases, drop-in overrides that survive package upgrades, and reading journald like a database instead of grepping flat files. Everything assumes a modern distribution — systemd v245 or newer with the unified cgroup v2 hierarchy as default. Confirm yours with systemctl --version and stat -fc %T /sys/fs/cgroup/ (you want the answer cgroup2fs).

By the end you will write a production service unit from a blank file — one with a real readiness gate, a crash-loop circuit breaker, a memory ceiling that reclaims before it kills, a dropped-capability set, and a seccomp allowlist — validate it before it ever runs, migrate a cron job to a timer that catches up on missed runs, and diagnose a slow boot or an ordering cycle from the journal. The tables are the reference you keep open; the prose explains the why.

What problem this solves

Before systemd, keeping a daemon alive in production meant a supervisor (runit, supervisord, monit), a PID file the daemon might or might not write correctly, an init script full of start-stop-daemon incantations, ulimit lines in a wrapper, nice/ionice prefixes, a cron entry for the periodic job with its own >> logfile 2>&1 redirection, logrotate config for that logfile, and — if you cared about security — an AppArmor or SELinux profile authored and debugged separately. Six tools, six config formats, six ways to get it subtly wrong. The classic failures were legion: a daemon that “started” (forked) but wasn’t ready when the next service tried to connect; a cron job that silently didn’t run because the machine was asleep at 2am; a memory leak that took down the whole box because nothing capped the process; a log that filled /var because nobody rotated it.

systemd collapses all of that into one declarative unit file that the init system — which is PID 1, present on every boot, aware of every dependency — enforces directly. What breaks without this knowledge is subtle and expensive: engineers put a crash-loop limiter in [Service] where it is silently ignored, order a service on network.target when it needed network-online.target and it fails intermittently on reboot, use Requires= where Wants= belonged and cascade a non-critical dependency failure into a full outage, or edit a vendor-shipped unit in place so the next package upgrade quietly reverts their fix. Each of these is a one-line mistake that costs hours of incident time because the symptom is far from the cause.

Who hits this: everyone running Linux servers — which is to say, everyone running anything on AWS, GCP, Azure VMs, or on-prem. It bites hardest on teams migrating off SysV init scripts (the mental model doesn’t transfer), teams running memory-hungry JVM or Node services on shared boxes (no resource limits, noisy-neighbour outages), teams that “replaced cron with a systemd timer” but never set Persistent= (missed runs on machines that sleep), and anyone who thinks “the service started” means “the service is ready.” The fix is almost never a bigger box or another supervisor — it is understanding the contract the unit file already offers you for free.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable on a Linux command line as root or with sudo: editing files under /etc, reading process output from ps, and understanding that a “daemon” is a long-running background process. You should know what a TCP port and a Unix socket are, what an environment variable is, and roughly what “the kernel schedules CPU time” means. No prior systemd depth is assumed beyond having typed systemctl status once. Familiarity with INI-style config (sections in [brackets], Key=Value lines) helps because that is exactly the unit-file format.

This sits at the foundation of the Linux server operations track — almost everything else builds on it. Resource control here is the mechanism behind Methodical Linux Performance Tuning: tuned, sysctl, and I/O Schedulers, which layers system-wide tuning on top of the per-service cgroup limits you set here. The sandboxing section is the systemd-native complement to full mandatory-access-control in Authoring AppArmor Profiles: Confining Services on Ubuntu and Debian and Running SELinux in Enforcing Mode: Troubleshooting and Writing Custom Policy — systemd directives handle the common cases; MAC handles the rest. Timers and units are the substrate for Running Rootless Containers in Production with Podman and Quadlet, where Quadlet generates systemd units for containers, and journald is one input to Building a Linux Audit Trail with auditd and eBPF Runtime Visibility.

A quick map of who owns what during an incident, so you look in the right place first:

Layer What lives here Failure it causes First tool
PID 1 (systemd manager) Unit parsing, dependency graph, boot orchestration Ordering cycles, slow boot, unit fails to load systemd-analyze, systemctl status
[Unit] section Dependencies, ordering, crash-loop limits Wrong start order, cascading failures systemctl list-dependencies
[Service] section Process type, restart, resources, sandbox Never-ready, crash loop, OOM, over-hardened death systemctl cat, journalctl -u
[Install] section Enable-time wiring to a target Enabled unit never starts at boot systemctl is-enabled
cgroup v2 hierarchy CPU/mem/IO accounting and limits Noisy neighbour, OOM kill, throttling systemd-cgtop, systemctl status
journald Structured, indexed logs Lost logs on reboot, dropped burst lines journalctl, journalctl --disk-usage

Core concepts

Five mental models make every later section obvious.

A unit is a typed, declarative object that PID 1 manages. systemd does not run shell scripts on your behalf and forget about them; it loads units — objects of a specific type identified by the filename suffix (.service, .timer, .socket, .target, .slice, .mount, .path, .scope, .device, .swap) — into an in-memory dependency graph, resolves that graph, and drives each unit through a state machine (inactive → activating → active → deactivating → failed). Because everything is a unit, the same verbs (start, stop, enable, status) and the same dependency mechanics apply uniformly whether the thing is a daemon, a mount point, a periodic timer, or an abstract grouping point.

“Started” is not “ready.” This is the single most consequential idea. With the default Type=simple, systemd considers your service started the instant it successfully forks the process — before the process has parsed its config, bound its socket, or connected to its database. Any service ordered After= yours may launch into a service that isn’t actually serving yet. Genuine readiness requires either Type=notify (the service explicitly signals READY=1 via sd_notify(3) when it can serve) or socket activation (systemd holds the socket, so the dependent can connect immediately and its first bytes queue until your service is up). Every “works on reboot, breaks under load” race traces back to conflating started with ready.

Dependency and ordering are orthogonal. Requires=B says “if I start, pull B in, and if B fails, fail me too” — it is about whether B runs. After=B says “do not start me until B has finished starting” — it is about when, and says nothing about pulling B in. You almost always need both, and each without the other is a bug: Requires= without After= starts them in parallel (a race); After= without Wants=/Requires= orders a dependency that might not even be present. Internalise the two-axis model and half of systemd’s surprises evaporate.

Every service lives in a cgroup, and cgroups are a tree. On cgroup v2 (unified hierarchy) systemd places each service in its own control group, nested under a slice. The default tree is -.slice (root) → system.slice (system daemons), user.slice (per-user sessions), and machine.slice (VMs/containers). Because a cgroup is the unit of accounting and control, you cap CPU, memory, and IO with [Service] directives that map directly onto kernel controllers — no ulimit guesswork, no external cgcreate. Slices let you set a shared budget across a group of services: put your batch jobs in system-batch.slice with a hard CPU and memory ceiling and they can never starve the foreground app.

Hardening is subtractive and additive at once. systemd’s sandbox works by removing capability from the service (drop Linux capabilities, filter syscalls, make the filesystem read-only, hide /home, mask kernel tunables) — every directive shrinks the blast radius of a compromise. The catch is that removing too much breaks a legitimate service on an unrelated syscall or path, so hardening is empirical: add a directive, restart the running service (not just check that it starts), watch for EPERM/SIGSYS, and repeat. The systemd-analyze security tool scores the result so you have a target to drive down.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary at the end repeats these for lookup; this table is the mental model side by side.

Term One-line definition Where it lives Why it matters
Unit A typed object systemd manages *.service, *.timer, etc. The atom of everything below
[Unit] Metadata, dependencies, ordering, limits Top of the file Controls whether/when it runs
[Service] Process type, exec, restart, resources, sandbox Middle of the file Controls how it runs
[Install] Enable-time wiring to a target Bottom of the file Makes enable actually work
Type= When systemd calls the service “started” [Service] Started ≠ ready — the top bug
Wants=/Requires= Dependency (pull in / hard-require) [Unit] Whether a dep runs
After=/Before= Ordering only [Unit] When it runs relative to others
Target An abstract grouping/sync point *.target Boot milestones (multi-user.target)
Slice A cgroup grouping for shared limits *.slice Tiered CPU/mem/IO budgets
Drop-in An override fragment merged on a unit *.d/override.conf Change vendor units safely
cgroup v2 Unified kernel resource-control tree /sys/fs/cgroup/ The mechanism for all limits
journald Structured, indexed system log /var/log/journal or /run Where every unit’s logs go
sd_notify Readiness/watchdog signalling protocol Service code Real readiness + liveness gates

The unit types, end to end

The filename suffix declares the type, and each type manages a different kind of thing. You author .service, .timer, .socket, .slice, and .path units by hand; .mount, .device, .swap, and most .target units are usually generated or shipped, but you should recognise them. Here is the complete set:

Suffix Manages You author it? Typical example
.service A process (daemon or one-shot task) Constantly nginx.service, widgetd.service
.socket A socket/FIFO for socket activation Sometimes sshd.socket, docker.socket
.timer A trigger that starts a unit on a schedule Often backup.timer, logrotate.timer
.target An abstract grouping / synchronisation point Rarely multi-user.target, network-online.target
.slice A cgroup node for grouped resource limits Sometimes system.slice, system-batch.slice
.mount A filesystem mount point Rarely (fstab-generated) home.mount, var-lib.mount
.automount On-demand mounting of a .mount Rarely proc-sys-fs-binfmt_misc.automount
.path Filesystem path watch that triggers a unit Sometimes cups.path (watch a spool dir)
.scope An externally-created group of processes No (API-created) A user login session, machine-run.scope
.device A kernel device exposed as a unit No (udev-generated) dev-sda.device
.swap A swap device/file Rarely (fstab-generated) swap.swap

Three of these deserve a mental model beyond the one-liner.

Targets are the closest systemd has to SysV runlevels, but richer: a target pulls in a set of units and provides a synchronisation point. multi-user.target is “multi-user text-mode system up” (the old runlevel 3); graphical.target adds the display manager (runlevel 5); network-online.target is “the network is actually configured and routable.” You rarely write a target, but you constantly reference them — WantedBy=multi-user.target in [Install] is how a service wires itself into boot, and After=network-online.target is how it waits for the network. The default target the system boots into is set by a symlink:

systemctl get-default            # e.g. multi-user.target or graphical.target
sudo systemctl set-default multi-user.target   # boot to text mode (no GUI)

Sockets enable socket activation: systemd creates and listens on the socket itself, then starts your service only when the first connection arrives (or eagerly, if you prefer), passing it the already-bound file descriptor. Two payoffs: on-demand start (the service consumes nothing until used) and zero-lost-connection restarts (systemd holds the socket across a service restart, so client connections queue instead of getting connection-refused). A .socket unit and its .service share a name stem; systemd links them automatically.

Slices are cgroup nodes. The name encodes the tree with - as the separator: system-batch.slice nests under system.slice, which nests under -.slice (root). You set resource limits on a slice and every service in it shares that budget — the cleanest way to guarantee background work can never starve foreground work.

Where unit files live, and who wins

Service units are searched in several directories in strict priority order — the first match wins, and a higher-priority file masks a lower one entirely (a zero-length or /dev/null-symlinked file disables the unit). Know these paths cold, because “I edited the unit and nothing changed” is almost always editing the wrong copy:

Path Purpose Priority Rule
/etc/systemd/system/ Your local custom units and overrides Highest This always wins; put your units here
/run/systemd/system/ Runtime-generated, volatile Middle Vanishes on reboot; generators use it
/usr/lib/systemd/system/ Shipped by distribution packages Lowest Never edit directly — upgrades revert it
/etc/systemd/system/<unit>.d/*.conf Drop-in overrides on any unit Merged on top The safe way to tweak a vendor unit
/etc/systemd/user/, ~/.config/systemd/user/ Per-user (rootless) units User scope For systemctl --user services

To customise a package-shipped unit, you never copy it into /etc — you add a drop-in (covered below), which merges on top and survives upgrades because the base file stays untouched. To see the effective, merged result of a unit plus all its drop-ins, run systemctl cat <unit>.

Anatomy of a unit file: the three sections

A .service unit has up to three sections. [Unit] and [Install] are generic (they appear in other unit types too); [Service] is service-specific. Here is what each section owns and the highest-value keys in each.

The [Unit] section — identity, dependency, ordering

[Unit] carries human metadata and the entire dependency/ordering graph. The keys you use constantly:

Key Purpose Axis Note
Description= Human-readable name (shows in status, journal) Metadata Keep it short and specific
Documentation= URI(s) to docs/runbook Metadata man:, https:, file: — great for on-call
Wants= Pull in unit(s), soft Dependency Failure of the wanted unit does not fail you
Requires= Pull in unit(s), hard Dependency If it fails/stops, you are stopped too
Requisite= Require it be already active Dependency Fails immediately if the dep isn’t up (no pull-in)
BindsTo= Like Requires=, but stops you if it stops for any reason Dependency Tightly couple to a device/mount
PartOf= Restart/stop propagates from the named unit to you Dependency Group a leader + followers
Conflicts= Starting you stops the named unit (and vice versa) Dependency Mutually exclusive units
After= Do not start until named unit finished starting Ordering Says nothing about pulling it in
Before= Start before the named unit Ordering The inverse of After=
OnFailure= Unit(s) to start if this one enters failed Reaction Fire an alert/handler unit on failure
StartLimitIntervalSec= Window for the crash-loop counter Rate limit Lives here, not in [Service]
StartLimitBurst= Max starts in that window before giving up Rate limit Exceed → unit marked failed, stops trying
ConditionPathExists= Skip (not fail) the unit unless a path exists Condition Condition* skip silently; Assert* fail loudly

Two of these are landmines. StartLimitIntervalSec and StartLimitBurst live in [Unit], not [Service] — put them in [Service] and they are silently ignored, so your crash-loop breaker does nothing and a broken service spins forever. And Conditions differ from Asserts: a failed Condition* marks the unit as skipped (success, it just didn’t run), while a failed Assert* marks it failed. Use ConditionPathExists=/etc/widgetd/enabled to make a unit opt-in without erroring when the file is absent.

The [Service] section — how the process runs

[Service] is the largest section: process type, the commands, restart policy, timeouts, the watchdog, resource control, and the entire sandbox. The core (non-resource, non-sandbox) keys:

Key Purpose Common values Gotcha
Type= When the service counts as “started” simple, exec, forking, oneshot, notify, dbus, idle See the Type table below — the top misconfig
ExecStart= The command to run Absolute path + args Must be absolute; not a shell unless you invoke one
ExecStartPre= / ExecStartPost= Commands before/after start Absolute paths Prefix - to ignore failure of that line
ExecReload= How to reload config /bin/kill -HUP $MAINPID $MAINPID expands to the main PID
ExecStop= Custom stop command Absolute path Usually unneeded; systemd sends SIGTERM
Restart= When to auto-restart no, on-failure, on-watchdog, always, on-abnormal on-failure is the sane default
RestartSec= Delay before restart e.g. 2 or 2s Avoid a tight restart loop
PIDFile= Where a forking daemon writes its PID Absolute path Mandatory for Type=forking
RemainAfterExit= Treat a finished process as still “active” yes/no Pair with Type=oneshot for “apply once”
TimeoutStartSec= Max time to become “started” seconds / infinity Slow boot init trips this
TimeoutStopSec= Max time to stop before SIGKILL seconds Too short → data loss on shutdown
WatchdogSec= Liveness deadline via sd_notify seconds Requires Type=notify + code support
Environment= / EnvironmentFile= Inject env vars KEY=val / a file path EnvironmentFile=-/path tolerates absence
User= / Group= Run as this identity a username Prefer DynamicUser=yes where possible
WorkingDirectory= cwd for the process a path or ~ Defaults to /

The [Install] section — enable-time wiring

[Install] is inert while the service runs; it only takes effect when you run systemctl enable. It describes how the unit should be wired into the boot graph. The keys:

Key Purpose Typical value Effect of enable
WantedBy= Which target should Wants= this unit multi-user.target Creates a symlink in multi-user.target.wants/
RequiredBy= Which target should Requires= this unit rarely used Symlink in <target>.requires/
Also= Enable/disable these units together a companion unit e.g. enable a .socket with its .service
Alias= Additional name(s) for the unit foo.service Symlink so both names work
DefaultInstance= Default instance for a template an instance string For @-template units

The gotcha is stark: no [Install] section means systemctl enable has nothing to do, so the unit never starts at boot — even though systemctl start works fine right now. Engineers write a perfect unit, start it, confirm it runs, walk away, and discover after the next reboot that it never came up. The fix is one block:

[Install]
WantedBy=multi-user.target

enable reads WantedBy=multi-user.target and creates the symlink /etc/systemd/system/multi-user.target.wants/<unit> — that symlink is what makes the unit start at boot. Confirm with systemctl is-enabled <unit> (expect enabled).

Service Type=: when is it “started”?

The Type= field tells systemd how to decide the service has finished starting — which gates when After=-ordered units may launch, and whether ExecStartPost= runs against a ready service. Getting it wrong is the most common systemd bug in the field. All the values:

Type= “Started” means… Readiness gating When to use it Trade-off / gotcha
simple The fork succeeded (default when ExecStart= set, no Type=) None — ordered units may start before yours is ready Trivial foreground processes where readiness doesn’t matter The classic race: dependents launch too early
exec execve() of the binary succeeded Catches a missing binary / bad perms immediately, still no app-readiness Prefer over simple for foreground daemons Better than simple; still not app-ready
forking The parent exits after forking the real daemon Parent-exit = “ready” (traditional daemonize) Legacy daemons that double-fork Must set PIDFile= or systemd loses the process
oneshot The process runs to completion and exits Blocks ordering until it finishes “Apply config once” tasks, timer payloads Pair with RemainAfterExit=yes to stay “active”
notify The service calls sd_notify(READY=1) True readiness — dependents wait until it can serve Any daemon whose framework supports sd_notify The gold standard; needs code/library support
notify-reload Like notify, with explicit reload signalling Readiness + a clean reload handshake Services that reload config live systemd v253+; needs MONITOR/reload support
dbus The service takes its name on the D-Bus Ready when the bus name appears D-Bus services Requires BusName= and a bus
idle Like simple but delayed until other jobs settle Cosmetic — avoids interleaving console output at boot Boot-time console programs Do not use for real readiness

The hierarchy in one sentence: prefer notify when your framework supports it; use exec over bare simple otherwise; use forking only for legacy double-forking daemons (and always set PIDFile=); use oneshot for tasks that run and exit. The reason notify matters is concrete — a database ordered After= a Type=simple app races the app’s own socket bind, so on a busy boot the database connection is refused, the app crashes, and it “works after a restart” because the second time the race falls the other way. Switch the app to Type=notify, have it call sd_notify(0, "READY=1") only after its socket is bound and its pool is primed, and the race is gone.

Dependencies vs ordering: the two-axis model

This is the concept that generates the most confusion and the most subtle outages, so it earns its own section. There are two independent questions about any two units A and B:

  1. Should starting A cause B to start too? — the dependency axis (Wants=, Requires=, Requisite=, BindsTo=).
  2. If both are starting, which goes first? — the ordering axis (After=, Before=).

They do not imply each other. Requires=B with no After=B pulls B in but starts A and B in parallel — a race. After=B with no Wants=/Requires=B orders A after B if B happens to be present, but does nothing to ensure it is. You almost always want both together. The full matrix of dependency strengths:

Directive Pulls the dep in? If dep fails to start If dep stops later Use when
Wants= Yes You continue (soft) No effect on you The default — dep is helpful but not fatal
Requires= Yes You fail too You are stopped Genuinely hard dependency
Requisite= No (must already be active) You fail immediately No effect Dep must be up already; don’t start it
BindsTo= Yes You fail too You are stopped for any reason (even device removal) Couple to a device/mount that can vanish
PartOf= No Stop/restart of the named unit propagates to you Leader unit controls followers
Conflicts= Negative — starting you stops the named unit Mutually exclusive units

Default to Wants= and reserve Requires=/BindsTo= for genuinely hard dependencies. The classic over-coupling bug: a web app declares Requires=redis.service because it uses Redis for caching; Redis has a blip and restarts; because of Requires=, systemd stops the web app too, turning a degraded-cache event (which the app could ride out) into a full outage. Wants=redis.service would have kept the app up. “Start my app after the database, and pull the database in if it isn’t running” is the canonical correct pair:

[Unit]
Description=Widget API daemon
Wants=postgresql.service
After=postgresql.service network-online.target

Note network-online.target, not network.target. This distinction bites constantly:

Target Meaning Blocks until… Use for
network.target Ordering point for the networking stack Networking service started configuring (not done) Ordering the network stack itself
network-online.target Network is actually up and routable An address is configured and reachable Services needing a routable address at start
network-pre.target Before any network config Firewall rules that must precede networking

If your service needs a routable IP the moment it starts (it connects to a remote database or API at boot), you need both After=network-online.target and Wants=network-online.target — and the relevant wait-online service (systemd-networkd-wait-online or NetworkManager-wait-online) must be enabled, or the target completes instantly and gates nothing. Ordering on plain network.target is the number-one “fails intermittently on reboot, fine on restart” cause.

Writing a production service unit

Enough theory — here is a complete, opinionated unit for a long-running daemon, with every deliberate choice explained. Save it as /etc/systemd/system/widgetd.service:

[Unit]
Description=Widget API daemon
Documentation=https://internal.example.com/runbooks/widgetd
Wants=network-online.target
After=network-online.target postgresql.service
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=notify
ExecStart=/usr/local/bin/widgetd --config /etc/widgetd/config.toml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2
TimeoutStartSec=30
TimeoutStopSec=20
WatchdogSec=30

# Run as a transient, system-managed identity with private state dirs
DynamicUser=yes
StateDirectory=widgetd
RuntimeDirectory=widgetd

[Install]
WantedBy=multi-user.target

The reasoning behind each non-obvious choice:

The values in this template you will tune per service:

Setting This template Tune up when Tune down when
RestartSec 2 A downstream needs time to recover Fast, safe local restart is fine
TimeoutStartSec 30 Startup legitimately runs migrations You want fast failure detection
TimeoutStopSec 20 Graceful drain needs longer Process stops instantly
WatchdogSec 30 Latency spikes cause false positives You want faster hang detection
StartLimitBurst 5 Flaky-but-recoverable dependency You want to give up faster on a broken build

Load and start it — daemon-reload is mandatory after any unit change because systemd caches parsed units in memory:

sudo systemctl daemon-reload
sudo systemctl enable --now widgetd.service
systemctl status widgetd.service

Replacing cron with systemd timers

Timers are a strictly better cron for anything you want to observe: full journald logging (no >> /var/log/x 2>&1 and no logrotate to configure), dependency ordering (run only After= the database is up), resource control (the timer’s service is just a service, so it inherits cgroup limits and sandboxing), and Persistent= to catch up on runs missed while the machine was off. A timer is always two units: a .service that does the work and a .timer that triggers it.

/etc/systemd/system/backup.service:

[Unit]
Description=Nightly database backup
Wants=postgresql.service
After=postgresql.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/db-backup.sh
DynamicUser=yes
StateDirectory=db-backup

/etc/systemd/system/backup.timer:

[Unit]
Description=Run database backup nightly at 02:30

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300
AccuracySec=1m

[Install]
WantedBy=timers.target

You enable the timer, not the service (the timer starts the service on schedule):

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers --all

The [Timer] directives

Timers come in two flavours: monotonic (relative to an event like boot or the last run) and realtime/calendar (wall-clock). The full directive set:

Directive Kind Meaning Example Note
OnCalendar= Realtime Fire at wall-clock times *-*-* 02:30:00 The cron replacement
OnBootSec= Monotonic Fire N after boot 15min Relative to system boot
OnStartupSec= Monotonic Fire N after systemd (manager) start 5min Manager, not machine, boot
OnActiveSec= Monotonic Fire N after the timer itself activates 30s Rarely used alone
OnUnitActiveSec= Monotonic Fire N after the service last activated 15min “Every 15 minutes since it last ran”
OnUnitInactiveSec= Monotonic Fire N after the service last deactivated 1h Interval measured from finish
Persistent= Store last run on disk; catch up missed runs true The anacron behaviour — usually want it
RandomizedDelaySec= Add random jitter up to N to each fire 300 Stop a fleet stampeding a target
FixedRandomDelay= Make the jitter stable per machine true Same offset every run on this host
AccuracySec= Coalescing window to reduce wakeups 1m (default) Tighten only if you need precision
Unit= Which unit to start (if not the name stem) other.service Defaults to the same name
WakeSystem= Wake the machine from suspend to fire true Needs RTC/ACPI support

Three of these do the heavy lifting. Persistent=true records the last run on disk; if the machine was off at 02:30, the job fires shortly after the next boot — the cron-with-anacron behaviour you almost always want and the single best reason to switch. RandomizedDelaySec= jitters the start so a hundred machines don’t all hit the backup target at exactly 02:30:00. AccuracySec= lets systemd batch nearby timers to reduce wakeups (great on laptops and dense hosts); the default one minute is fine unless you genuinely need second precision.

For “every 15 minutes” style intervals, use the monotonic pair — OnBootSec= for the first run after boot and OnUnitActiveSec= for the repeat:

[Timer]
OnBootSec=15min
OnUnitActiveSec=15min

Writing and validating OnCalendar expressions

OnCalendar= uses the grammar DayOfWeek Year-Month-Day Hour:Minute:Second, where * is a wildcard, .. is a range, / is a step, and , is a list. Common patterns:

You want… OnCalendar= expression Notes
Daily at 02:30 *-*-* 02:30:00 Any day
Weekdays at 09:00 Mon..Fri 09:00 Time can omit :00 seconds
First of every month, midnight *-*-01 00:00:00 Monthly
Every 15 minutes *:0/15 Step on the minute field
Every hour on the hour *-*-* *:00:00 or hourly hourly is a named shorthand
Twice daily (00:00, 12:00) *-*-* 00,12:00:00 List on the hour field
Sundays at 04:00 Sun *-*-* 04:00:00 Day-of-week prefix
Named shorthands minutely, hourly, daily, weekly, monthly, yearly Convenient common cases

Always validate an expression before trusting itsystemd-analyze calendar normalises it and prints the next fire times, which is the fastest way to prove it means what you think:

systemd-analyze calendar "Mon..Fri 09:00" --iterations 5
# Prints Normalized form and the next 5 elapse timestamps

Timers versus cron, feature by feature, so the migration case is explicit:

Capability cron systemd timer Why it matters
Logging You redirect to a file Automatic in journald, per-unit, structured journalctl -u backup.service just works
Catch up missed runs Needs anacron Persistent=true Machines that sleep don’t skip jobs
Jitter across a fleet Manual sleep $RANDOM hacks RandomizedDelaySec= No 02:30:00 stampede
Dependency ordering None After=/Wants= in the service Run only after the DB is up
Resource limits None (or a wrapper) Full cgroup control on the service Cap the backup’s CPU/IO
Sandboxing None All [Service] hardening applies The job runs confined
Per-run status / history None systemctl status, list-timers See last result and next run
Randomised but stable offset No FixedRandomDelay=true Same host, same offset each day

Resource control with cgroup v2

Every service gets its own cgroup, so you control CPU, memory, IO, and task count with [Service] directives (or a drop-in) that map straight onto kernel controllers — no ulimit guesswork and no manual cgcreate. A representative block:

[Service]
# CPU: hard-cap at 1.5 cores; weight only matters under contention
CPUQuota=150%
CPUWeight=200

# Memory: soft-throttle at 512M, hard OOM-kill the cgroup at 768M
MemoryHigh=512M
MemoryMax=768M

# Block IO, per device
IOWeight=100
IOWriteBandwidthMax=/dev/sda 20M

# Fork-bomb protection
TasksMax=256

CPU control

Directive Kind Range / units Meaning When to use
CPUQuota= Absolute % (can exceed 100% = >1 core) Hard ceiling on CPU time Cap a noisy service at N cores regardless of idle capacity
CPUWeight= Relative 110000 (default 100) Proportional share under contention only Prioritise foreground over batch when they compete
AllowedCPUs= Pinning CPU list, e.g. 0-3 Pin to specific cores (cpuset) Isolate latency-sensitive work
CPUAccounting= Toggle yes/no Turn on CPU accounting Needed for real numbers if zeros show

The distinction: CPUQuota=150% allows 1.5 cores of CPU time even if the box is otherwise idle — an absolute ceiling. CPUWeight=200 only does anything when cgroups compete for the CPU; it divides contended time proportionally (a weight-200 service gets twice the contended share of a weight-100 one) and is invisible on an idle machine.

Memory control

Directive Kind Meaning Behaviour on breach When to use
MemoryMax= Hard limit Absolute cap cgroup OOM killer reaps a process The last-resort ceiling
MemoryHigh= Soft limit Throttle threshold Kernel aggressively reclaims + stalls the cgroup (no kill) Let the workload shed memory first
MemoryLow= Protection Reclaim protection floor Memory below this is protected from reclaim Guarantee a service a working set
MemoryMin= Hard protection Hard reclaim floor Never reclaimed below this Critical service must keep RAM
MemorySwapMax= Swap cap Max swap for the cgroup Swap use capped Limit swap thrash
MemoryAccounting= Toggle Turn on memory accounting Needed for real numbers

The pattern that matters: set MemoryHigh below MemoryMax so the workload gets a chance to reclaim and stall (MemoryHigh) before the hammer of the OOM killer (MemoryMax) drops. A JVM with MemoryHigh=3500M MemoryMax=4G can shed heap under GC pressure at 3.5G instead of being killed mid-request at 4G.

IO and task control

Directive Kind Meaning Requires device path When to use
IOWeight= Relative Proportional IO share (110000, default 100) No Prioritise IO under contention
IOReadBandwidthMax= Absolute Read bandwidth cap per device Yes Throttle a service’s disk reads
IOWriteBandwidthMax= Absolute Write bandwidth cap per device Yes Cap a log-heavy or backup service
IOReadIOPSMax= / IOWriteIOPSMax= Absolute IOPS cap per device Yes Bound random-IO pressure
TasksMax= Absolute Max PIDs (threads+processes) in the cgroup No Fork-bomb guard; infinity to disable
IOAccounting= Toggle Turn on IO accounting No Needed for real numbers

IOWeight= is proportional like CPUWeight=; the *BandwidthMax=/*IOPSMax= directives are absolute per-device caps and require the device path (/dev/sda). TasksMax=256 is a cheap, effective fork-bomb guard — cap the PIDs a compromised or buggy service can spawn.

Applying and inspecting limits live

Apply resource limits without editing the unit using set-property, which writes a drop-in and applies it immediately, then inspect the live accounting:

# Write a drop-in and apply it live (survives reboot)
sudo systemctl set-property widgetd.service MemoryMax=1G CPUQuota=200%

# Confirm the effective values
systemctl show widgetd.service -p MemoryMax -p CPUQuota -p TasksMax

# top for cgroups — per-service CPU / memory / IO, live
systemd-cgtop

systemd-cgtop is the fastest way to find the noisy neighbour on a box — it is top for cgroups, sorted by resource. If systemctl show reports zeros, accounting is off; set MemoryAccounting=yes/CPUAccounting=yes explicitly (most distros enable them by default, but not all).

Slices: shared budgets for a group of services

A slice is a cgroup node that groups services under a shared limit — the clean way to guarantee background work can never starve foreground work. Create /etc/systemd/system/batch.slice:

[Unit]
Description=Batch/background workload slice (capped)

[Slice]
CPUQuota=200%
MemoryMax=4G
IOWeight=50

Then assign services to it with Slice=batch.slice in their [Service] section. Every service in batch.slice now shares that 2-core, 4 GB, low-IO-priority budget, collectively — no single batch job, and no sum of them, can exceed it. The default slice layout you build on:

Slice Contains Default role You customise it to…
-.slice Everything (root of the tree) The cgroup root Rarely touch directly
system.slice System daemons started by systemd Where your services land by default Set a system-wide ceiling
user.slice Per-user login sessions Interactive users Cap runaway user sessions
machine.slice VMs and containers (machinectl) Virtualised guests Bound guest resource use
system-<name>.slice A custom sub-slice under system.slice Your tiering e.g. system-batch.slice for background jobs

Sandboxing and service hardening

This is where systemd quietly replaces a pile of AppArmor/SELinux work for common cases. Each directive shrinks the blast radius of a compromised process. A hardened [Service] block:

[Service]
# Filesystem
ProtectSystem=strict              # whole filesystem read-only...
ReadWritePaths=/var/lib/widgetd   # ...except these
ProtectHome=true
PrivateTmp=true

# Privilege
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

# Kernel / device / syscall exposure
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
PrivateDevices=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service
SystemCallArchitectures=native
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictSUIDSGID=true

The directives, grouped by what they defend, with the high-value ones flagged:

Directive Defends Effect Priority Breaks if…
NoNewPrivileges=true Privilege escalation Process + children can never gain privs (setuid/fscaps ignored) Highest Service legitimately needs to escalate
ProtectSystem=strict Filesystem writes Entire FS read-only in the service’s namespace High Forgot to allow needed write paths
ReadWritePaths= (companion to above) Punch writable holes in a read-only FS Path list incomplete
ProtectHome=true User data /home, /root, /run/user hidden High Service needs a home dir (rare)
PrivateTmp=true Temp-file attacks Private /tmp and /var/tmp namespace High Something shares /tmp out of band
DynamicUser=yes Everything Transient UID + implies many protections Highest Service needs a fixed UID / persistent file ownership
CapabilityBoundingSet= Kernel capabilities Drops every capability not listed High You omitted a capability the service needs
AmbientCapabilities= (companion) Grants a listed cap to an unprivileged process Used without matching bounding set
SystemCallFilter=@system-service Kernel attack surface Seccomp allowlist of “normal” syscalls High Service uses an exotic/filtered syscall
SystemCallArchitectures=native ABI confusion Blocks non-native syscall ABIs (e.g. x32) Medium 32-bit compat legitimately needed
RestrictAddressFamilies= Network surface Allow only listed socket families Medium Service uses AF_NETLINK/AF_PACKET etc.
ProtectKernelTunables=true /proc//sys writes /proc/sys, /sys read-only Medium Service tunes sysctls at runtime
ProtectKernelModules=true Module loading Cannot load/unload kernel modules Medium Almost never breaks a normal service
ProtectControlGroups=true cgroup tampering /sys/fs/cgroup read-only Medium Service manages cgroups itself
PrivateDevices=true Device access Only pseudo-devices; no raw /dev Medium Needs a real device node
MemoryDenyWriteExecute=true Code injection No W+X memory mappings Medium JIT runtimes (many JVMs, V8) break
LockPersonality=true ABI exploits Locks the execution personality Low Rare
RestrictSUIDSGID=true SUID creation Cannot create setuid/setgid files Medium Almost never breaks a normal service
RestrictNamespaces=true Namespace abuse Cannot create new namespaces Medium Container-in-service breaks

The four highest-value moves, in order: DynamicUser=yes (or NoNewPrivileges=true if you need a fixed user) is the keystone — turn it on everywhere unless the service genuinely needs to escalate; ProtectSystem=strict + ReadWritePaths= makes the filesystem read-only except where the service must write; CapabilityBoundingSet= drops every capability the service doesn’t need (the canonical case is a web server that needs only CAP_NET_BIND_SERVICE to bind port 80/443, granted to the unprivileged process via AmbientCapabilities=); and SystemCallFilter=@system-service is a seccomp allowlist of syscalls appropriate for typical services, implicitly blocking dangerous groups like @swap, @reboot, @module, and raw @debug.

The critical operational rule: hardening is empirical and must be tested against the running service, not just “it started.” A directive like SystemCallFilter or MemoryDenyWriteExecute can let a service start and then kill it on the first request that hits a filtered syscall or a JIT compile. Add one directive, restart, exercise the service, watch the journal for EPERM/Operation not permitted/SIGSYS, and only then add the next.

systemd ships a scoring tool — run it after hardening and treat the score as a checklist to drive down:

systemd-analyze security widgetd.service
# Rates each unit and directive from "UNSAFE" through "EXPOSED" to "OK",
# with a numeric exposure score and a per-directive breakdown.

The exposure score bands:

Score band Label Meaning Action
0.0–1.9 😀 OK Well sandboxed Good; maintain it
2.0–3.9 🙂 (medium) Reasonable Tighten the flagged directives
4.0–6.9 😐 EXPOSED Meaningful attack surface Harden the biggest contributors
7.0–10.0 😨 UNSAFE Little to no sandboxing Start with the keystone directives

Drop-in overrides and templated units

Drop-ins: never edit a shipped unit

To tweak a vendor unit, you do not copy it into /etc/systemd/system/. You add a drop-in — a fragment merged on top of the base unit, which survives package upgrades because the shipped file is untouched. The clean path:

sudo systemctl edit nginx.service

This opens an editor for /etc/systemd/system/nginx.service.d/override.conf. Add only the deltas:

[Service]
LimitNOFILE=65536
Restart=on-failure
RestartSec=5

Verify the merge and the effective value:

systemctl cat nginx.service              # base unit + every drop-in, in order
systemctl show nginx.service -p LimitNOFILE

The list-valued gotcha: most directives are additive or last-one-wins, but list-valued keys like ExecStart= are not simply overridden — a second ExecStart= appends, giving you two start commands. To replace one, clear it first with an empty assignment, then set the new value:

[Service]
ExecStart=
ExecStart=/usr/local/bin/nginx-wrapper

The same empty-then-set pattern applies to Environment=, ExecStartPre=, and any other list-valued directive. How the merge behaves by key type:

Directive kind Example keys Drop-in behaviour To replace
Scalar (last wins) Restart=, MemoryMax=, User= Last value across base + drop-ins wins Just set the new value
List (appends) ExecStart=, Environment=, After= New entries append to the list Empty-string reset, then set
Boolean toggle NoNewPrivileges=, PrivateTmp= Last value wins Set the new value

Templated (instanced) units

A unit file with @ in its name is a template. The string after @ is the instance, available in the file as %i (escaped) or %I (unescaped). One template file backs any number of running instances, each with independent state, logs, and lifecycle. /etc/systemd/system/tunnel@.service:

[Unit]
Description=SSH tunnel to %i
After=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/autossh -M 0 -N %i
Restart=always
RestartSec=10
DynamicUser=yes

[Install]
WantedBy=multi-user.target

Start as many instances as you like from the one file:

sudo systemctl enable --now tunnel@db-replica.service
sudo systemctl enable --now tunnel@cache-01.service

The commonly used specifiers:

Specifier Expands to Example
%i Instance name (escaped) db-replica
%I Instance name (unescaped) user@10.0.0.5
%n Full unit name tunnel@db-replica.service
%N Unit name without the type suffix tunnel@db-replica
%H Hostname the machine’s hostname
%h User home directory (user units) /home/vinod
%t Runtime dir (/run or per-user) /run
%% A literal % %

Use systemd-escape to safely build instance names containing slashes or special characters, which would otherwise break the unit name:

systemd-escape --template tunnel@.service "user@10.0.0.5"
# tunnel@user\x4010.0.0.5.service

Architecture at a glance

Picture PID 1 — the systemd manager — as the hub every arrow points to. At boot it reads unit files from the priority-ordered directories (/etc/systemd/system overriding /run overriding /usr/lib), parses each into a typed object, and assembles them into a single directed graph. Two kinds of edge run through that graph: dependency edges (Wants=/Requires=), which decide which units get pulled in, and ordering edges (After=/Before=), which decide the sequence. systemd computes a transaction that satisfies both sets simultaneously and drives units through their state machines toward the default target (usually multi-user.target) — the milestone that means “the system is up.” Targets like network-online.target sit in this graph as synchronisation points that gate everything ordered after them.

Below the graph sits the cgroup v2 tree, and this is the part most mental models miss: it is not a separate system bolted on, it is where every service lives. -.slice at the root branches into system.slice (your daemons), user.slice (login sessions), and machine.slice (guests); each service is a leaf cgroup nested under its slice, and the resource directives you write in [Service] (CPUQuota, MemoryMax, IOWeight, TasksMax) are values written into that leaf’s controller files. A custom system-batch.slice with its own ceiling is just another branch, and every service assigned to it shares that branch’s budget. Wrapping each service is its sandbox — a private mount namespace (from ProtectSystem/PrivateTmp), a reduced capability set, and a seccomp filter — so the process sees a stripped-down view of the machine.

To the side runs journald, receiving structured log records from every unit’s stdout/stderr and from sd_journal calls, tagged with the unit name, PID, priority, and boot ID, and indexed so journalctl -u <unit> -p err -b is a database query, not a grep. Timers hang off the graph too: a .timer unit is an edge that says “at this wall-clock time (or interval), start this .service.” Follow any incident backward through this picture — a failed unit → its journal → its place in the ordering graph → its cgroup accounting — and the diagnosis is mechanical rather than mysterious. There is no separate image for this article; the model above is the map to keep in your head.

Real-world scenario

Meridian Payments ran a JVM settlement service — call it settlementd — on a fleet of 12 bare-metal Linux hosts (systemd v249, cgroup v2), one instance per host, under a unit that a departing engineer had written years earlier. The unit was minimal: Type=simple, Restart=always, MemoryMax=4G, no watchdog, no start-limit, ordered on network.target. It processed batch settlements every few minutes, fanning out to a payment provider and a Postgres cluster. The platform team was five engineers; the fleet cost roughly ₹9,00,000/month in colocation and support.

The incident began during a payment-provider outage. The provider’s API started hanging — accepting connections but never responding — and settlementd’s worker threads blocked one by one on those calls. Within minutes every thread was parked, the heap filled with queued work, GC went into a thrash, and the JVM stopped making progress. But it did not die: the process was alive, the port was open, systemctl status was green. Because the unit had no watchdog, systemd saw a perfectly healthy service. Settlements silently backed up for 40 minutes until on-call noticed the queue depth on a business dashboard — not from any systemd alert.

It got worse. As the heap grew, the cgroup hit MemoryMax=4G and the cgroup OOM killer reaped the JVM mid-batch, corrupting an in-flight settlement file. Restart=always immediately restarted it — but because Type=simple reports “started” at fork, the fresh JVM took traffic before its DB pool had reconnected, threw a wall of connection errors, and the whole cycle began again. With no StartLimitBurst, systemd restarted it in a tight loop, each restart hammering the recovering Postgres cluster with a reconnect storm. A single upstream hang had turned into a self-inflicted outage.

The fix was a redesign of the unit around true liveness, not process-alive. They switched to Type=notify and added a dedicated watchdog thread that pinged sd_notify(WATCHDOG=1) only when the work loop was making progress and the DB pool was reachable — so a wedge trips it in seconds. They split the memory limits so the JVM could shed heap under pressure before being killed, and added a crash-loop breaker:

[Unit]
Description=Settlement processor
Wants=network-online.target postgresql.service
After=network-online.target postgresql.service
StartLimitIntervalSec=120
StartLimitBurst=4

[Service]
Type=notify
WatchdogSec=20
Restart=on-watchdog
RestartSec=5
MemoryHigh=3500M
MemoryMax=4G
TimeoutStopSec=45
// Ping the watchdog only when genuinely able to serve — every WatchdogSec/2 = 10s
if (workLoop.isMakingProgress() && dbPool.isReachable()) {
    sdNotify.notifyWatchdog();
}

The next provider outage was a non-event. The wedge tripped the watchdog in 20 seconds; Restart=on-watchdog cycled the service cleanly; MemoryHigh let the heap shed at 3.5G so the OOM killer never fired and no settlement file was corrupted; and StartLimitBurst=4 in a 120-second window meant that if the service itself were genuinely broken it would give up after four tries instead of storming Postgres. TimeoutStopSec=45 gave in-flight batches time to drain on a clean stop. The incident timeline, because the order of what changed is the lesson:

Change Before After What it fixed
Type= simple (ready at fork) notify (ready when it can serve) Restart no longer races the DB pool
Liveness none WatchdogSec=20 + progress-aware ping Detects a wedge in 20s, not 40 min
Restart trigger always (even on OOM race) on-watchdog Restarts on hang, not on every death
Memory MemoryMax=4G only MemoryHigh=3500M + MemoryMax=4G Heap sheds before OOM kill; no corruption
Crash-loop none StartLimitBurst=4 / 120s No reconnect storm on a broken build
Ordering network.target network-online.target + Wants= No boot-time race for a routable address

The line that went on the runbook: Restart= fires on death; a deadlocked service never dies. If you care about liveness, you want a watchdog, not a restart policy.”

Advantages and disadvantages

The unified-init-plus-cgroups-plus-sandbox model is powerful precisely because it is centralised — and that same centralisation is what people trip over. Weigh it honestly:

Advantages (why the model helps) Disadvantages (why it bites)
One declarative file replaces a supervisor, cron, ulimit wrapper, logrotate config, and a MAC profile The unit-file surface is large; the same power means many one-line ways to be subtly wrong
Type=notify + WatchdogSec give true readiness and liveness with no external health-checker Readiness/liveness require the service to cooperate (sd_notify) — not free for opaque binaries
cgroup v2 limits are per-service, live-tunable (set-property), and observable (systemd-cgtop) Accounting must be on for numbers to be real; a service with no limits can still be a noisy neighbour
Sandboxing directives replace much AppArmor/SELinux work and are self-scored (systemd-analyze security) Over-hardening kills a service on an unrelated syscall/path — you must test the running service, not just start it
Timers beat cron on logging, catch-up, jitter, ordering, and resource control “Migrated to a timer” without Persistent= silently loses missed runs on machines that sleep
Drop-ins let you customise vendor units and survive package upgrades List-valued keys append, not replace — a forgotten empty-reset gives you two ExecStart= lines
journald is structured, indexed, and per-unit — real queries, not grep Default journal is often volatile (/run); logs vanish on reboot unless you enable persistence
Templated units scale one file to N instances with independent state Specifier/escaping mistakes (%i vs %I, unescaped slashes) produce broken unit names

The model is right for essentially any Linux server workload — it is the default init on every mainstream distribution and the mechanism behind container runtimes (Quadlet, systemd-nspawn) too. It bites hardest on teams carrying SysV mental models forward, opaque third-party binaries that can’t call sd_notify, JIT runtimes that break under MemoryDenyWriteExecute, and anyone who copies a vendor unit into /etc instead of using a drop-in. Every disadvantage above is manageable — but only once you know it exists, which is the entire point of learning the contract rather than poking it with start/stop.

Hands-on lab

Build a real service unit from scratch, add resource limits and hardening, migrate a task to a timer, watch it in the journal, and tear it all down. Everything runs on a single modern Linux VM (Ubuntu 22.04+/Fedora 38+/Debian 12+) with sudo; no network services are required. Confirm your baseline first.

Step 1 — Confirm systemd version and cgroup v2.

systemctl --version | head -1
stat -fc %T /sys/fs/cgroup/

Expected: version >= 245, and cgroup2fs (the unified hierarchy). If you see tmpfs/cgroup, you are on the legacy hybrid v1 hierarchy and the resource directives behave differently.

Step 2 — Write a minimal service that runs a script. Create the payload:

sudo tee /usr/local/bin/labworker.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
echo "labworker starting (pid $$)"
# systemd-notify tells Type=notify we are ready; works in shell via the CLI
systemd-notify --ready 2>/dev/null || true
while true; do
  echo "labworker heartbeat $(date -Is)"
  sleep 5
done
EOF
sudo chmod +x /usr/local/bin/labworker.sh

Create the unit /etc/systemd/system/labworker.service:

sudo tee /etc/systemd/system/labworker.service >/dev/null <<'EOF'
[Unit]
Description=Lab worker (systemd deep-dive)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=notify
ExecStart=/usr/local/bin/labworker.sh
Restart=on-failure
RestartSec=2
DynamicUser=yes
StateDirectory=labworker

[Install]
WantedBy=multi-user.target
EOF

Step 3 — Validate before running, then start. Never skip the verify:

sudo systemd-analyze verify /etc/systemd/system/labworker.service   # silent = clean
sudo systemctl daemon-reload
sudo systemctl enable --now labworker.service
systemctl status labworker.service --no-pager

Expected: status shows Active: active (running) and — because Type=notify — a Status: line only after the script called systemd-notify --ready. systemctl is-enabled labworker.service returns enabled.

Step 4 — Watch its logs in the journal.

journalctl -u labworker.service -n 20 --no-pager
journalctl -u labworker.service -f          # follow; Ctrl-C to stop

Expected: labworker starting followed by labworker heartbeat … lines every 5 seconds, each tagged with the unit.

Step 5 — Apply resource limits live and inspect them.

sudo systemctl set-property labworker.service CPUQuota=50% MemoryMax=128M TasksMax=32
systemctl show labworker.service -p CPUQuota -p MemoryMax -p TasksMax
systemctl status labworker.service --no-pager | grep -A3 'Memory\|CPU'

Expected: CPUQuotaPerSecUSec reflects 50%, MemoryMax=134217728 (128 MiB), TasksMax=32. set-property wrote a drop-in under /etc/systemd/system/labworker.service.d/ — confirm with systemctl cat labworker.service (you will see the base unit plus a 50-CPUQuota.conf-style fragment).

Step 6 — Harden it and score the result. Add a drop-in via edit (use --full off; append only deltas). For a scripted lab, write the drop-in directly:

sudo mkdir -p /etc/systemd/system/labworker.service.d
sudo tee /etc/systemd/system/labworker.service.d/hardening.conf >/dev/null <<'EOF'
[Service]
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/labworker
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
LockPersonality=true
EOF
sudo systemctl daemon-reload
sudo systemctl restart labworker.service
systemctl status labworker.service --no-pager | head -5
systemd-analyze security labworker.service | tail -5

Expected: the service still runs (heartbeats continue — confirm with journalctl -u labworker.service -n 5), and the systemd-analyze security exposure score is markedly lower than before hardening. If the service had failed after a directive, that directive was too strict — the empirical loop in action.

Step 7 — Migrate a task to a timer. Create a oneshot service and a timer that fires every minute for the lab (you’d use OnCalendar= in production):

sudo tee /etc/systemd/system/labreport.service >/dev/null <<'EOF'
[Unit]
Description=Lab periodic report
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo "report run at $(date -Is), uptime: $(uptime -p)"'
DynamicUser=yes
EOF

sudo tee /etc/systemd/system/labreport.timer >/dev/null <<'EOF'
[Unit]
Description=Run the lab report every minute
[Timer]
OnBootSec=30s
OnUnitActiveSec=1min
Persistent=true
RandomizedDelaySec=10
[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now labreport.timer
systemd-analyze calendar "*-*-* 02:30:00" --iterations 3     # validate a real cron-style expr
systemctl list-timers labreport.timer --no-pager

Expected: list-timers shows labreport.timer with a NEXT fire time within a minute and its UNIT/ACTIVATES columns. Wait ~90 seconds, then read the results:

journalctl -u labreport.service -n 10 --no-pager

Expected: one or more report run at … lines, each a separate oneshot invocation triggered by the timer.

Validation checklist. You wrote a Type=notify service that gates on real readiness, gave it a crash-loop breaker, applied live cgroup limits, hardened it and verified it still runs under the sandbox, and replaced a periodic task with a logged, jittered, catch-up-capable timer. What each step proved:

Step What you did What it proves
3 systemd-analyze verify before start Units are validated offline, before they can fail live
3 Type=notify + systemd-notify --ready “Started” gates on readiness, not fork
5 set-property limits + systemctl show cgroup control is per-service, live, and observable
6 Hardening drop-in + security score Sandboxing is additive, testable, and scored
7 .service+.timer + list-timers Timers replace cron with logging, jitter, and catch-up

Teardown (leave the box clean):

sudo systemctl disable --now labworker.service labreport.timer
sudo rm -f /etc/systemd/system/labworker.service \
           /etc/systemd/system/labreport.service \
           /etc/systemd/system/labreport.timer \
           /usr/local/bin/labworker.sh
sudo rm -rf /etc/systemd/system/labworker.service.d
sudo systemctl daemon-reload
sudo systemctl reset-failed

Common mistakes & troubleshooting

The failure modes that eat the most incident time, as a symptom → cause → confirm → fix playbook. Read the table at 02:14; read the prose underneath once, now.

# Symptom Root cause Confirm (exact command / path) Fix
1 Edited the unit, nothing changed Forgot daemon-reload; systemd runs the cached copy systemctl show <u> -p ExecStart shows old value sudo systemctl daemon-reload after every unit edit
2 Crash-loop breaker never trips; service spins forever StartLimit* put in [Service], silently ignored systemctl cat <u> shows them under [Service] Move StartLimitIntervalSec/StartLimitBurst to [Unit]
3 Enabled unit doesn’t start at boot No [Install] section, so enable did nothing systemctl is-enabled <u> = static not enabled Add [Install] WantedBy=multi-user.target, re-enable
4 Dependent service races into a not-ready service Type=simple reports ready at fork systemctl show <u> -p Type = simple Switch to Type=notify (or socket activation)
5 Service fails intermittently on reboot, fine on restart Ordered on network.target, needs routable IP systemctl cat <u> shows After=network.target Use After=+Wants=network-online.target
6 A non-critical dependency blip takes the app down Requires= where Wants= belonged systemctl show <u> -p Requires lists the dep Change Requires= to Wants= for soft deps
7 Timer skips runs on a machine that sleeps Persistent= not set systemctl cat <u>.timer lacks Persistent=true Add Persistent=true under [Timer]
8 OnCalendar= never fires or fires at the wrong time Malformed expression systemd-analyze calendar "<expr>" errors / wrong times Fix per the grammar; re-validate
9 Two ExecStart= run after a drop-in List-valued key appended, not replaced systemctl show <u> -p ExecStart shows two entries Empty-reset (ExecStart=) then set the new one
10 Service dies mid-request after hardening A directive filters a needed syscall/path journalctl -u <u> shows EPERM/SIGSYS/Operation not permitted Remove/loosen the last directive; add back syscall group
11 Resource metrics show all zeros Accounting is off for that controller systemctl show <u> -p MemoryCurrent = [not set]/0 Set MemoryAccounting=yes/CPUAccounting=yes
12 Logs vanish after reboot Journal is volatile (/run/log/journal) journalctl --disk-usage tiny; ls /var/log/journal absent Storage=persistent + mkdir /var/log/journal
13 Chatty service missing log lines journald rate-limited the burst Journal shows Suppressed N messages Raise RateLimitBurst/RateLimitIntervalSec or fix the noise
14 forking daemon shows as failed / untracked No PIDFile= so systemd lost the real PID systemctl status <u> main PID wrong/absent Set PIDFile=, or switch the daemon to Type=notify/exec
15 Boot is slow One unit serialises the critical chain systemd-analyze critical-chain Fix the ordering / speed the unit named there
16 Unit refuses to start: “Found ordering cycle” A dependency loop; systemd broke it arbitrarily journalctl -b | grep -i 'ordering cycle' Relax one After=/Requires= in the loop

The entries that bite hardest, expanded:

2. Crash-loop breaker never trips. StartLimitIntervalSec and StartLimitBurst moved to the [Unit] section in modern systemd; put them in [Service] and they are silently dropped. A genuinely broken service then restarts forever, hammering downstreams. Confirm with systemctl cat <u> and move them to [Unit]. This is the single most common “why is it still restarting?” cause.

4. Started ≠ ready races. With Type=simple, a database ordered After= your app connects before the app has bound its listener, gets connection-refused, and crashes; the second boot the race falls the other way and it “works.” The fix is Type=notify with the app calling sd_notify(READY=1) after it can actually serve — or socket activation, where systemd holds the socket so the first connection queues instead of failing.

10. Over-hardening kills the running service. A service that starts fine under SystemCallFilter=@system-service may die on the first request that hits a filtered syscall, or a JIT runtime may SIGSYS under MemoryDenyWriteExecute=true. The journal shows Operation not permitted or a SIGSYS. Remove the last directive you added, confirm the service is healthy under load, then add syscall groups back one at a time (SystemCallFilter=@system-service @pkey etc.). Always test the running service, not just that it starts.

16. Ordering cycles. If your dependency graph has a loop, systemd breaks it by deleting a unit from the transaction — and it tells you which one in the journal (“Found ordering cycle on X, deleting Y to break it”). Do not let systemd choose: journalctl -b | grep -i 'ordering cycle' names the units, and you fix it by relaxing one After=/Requires= in the loop yourself.

The debugging commands worth memorising:

Question Command
What failed and why? systemctl --failed; systemctl status <u>; journalctl -u <u> -b
What is the effective, merged config? systemctl cat <u>; systemctl show <u> -p <Key>
Where is boot time going? systemd-analyze; systemd-analyze blame; systemd-analyze critical-chain
What pulls in what? systemctl list-dependencies <u>
Is a calendar/timer right? systemd-analyze calendar "<expr>"; systemctl list-timers --all
What’s using resources? systemd-cgtop; systemctl status <u> (Memory/CPU lines)
Is a unit valid? systemd-analyze verify /path/to/unit
How hardened is it? systemd-analyze security <u>

Reach for systemd-analyze critical-chain rather than blame when boot is slow: blame lists slow units in isolation, but critical-chain shows the serialised path that actually gated reaching the target, accounting for parallelism — the unit at the top of the chain is the one to fix.

Best practices

Security notes

Cost & sizing

systemd itself is free and already running — the “cost” is entirely in the compute you right-size with it and the incidents you prevent. The levers:

There is no free-tier or SKU dimension here — the sizing question is CPU/RAM/disk of the underlying host, and systemd’s contribution is letting you use less of it safely. Rough guidance: set MemoryMax at ~1.3× a service’s steady-state working set and MemoryHigh at ~0.9× MemoryMax; set CPUQuota only where a service has a genuine ceiling (leave latency-sensitive services uncapped but give them a high CPUWeight); and cap the journal at 1–4 GB on a general server, more on a log-heavy one.

Interview & exam questions

1. What is the difference between a dependency and an ordering directive, and why do you usually need both? Dependency (Wants=/Requires=) controls whether another unit is pulled in when this one starts; ordering (After=/Before=) controls when it runs relative to others. They are orthogonal: Requires= without After= starts both in parallel (a race), and After= without Wants=/Requires= orders a unit that may not even be present. You typically pair them — e.g. Wants=postgresql.service + After=postgresql.service.

2. Why is Type=simple dangerous, and what fixes it? Type=simple marks the service “started” the instant it forks — before it has bound sockets or connected to dependencies — so units ordered After= it can launch into a not-yet-ready service and race. The fix is Type=notify (the service calls sd_notify(READY=1) when it can genuinely serve) or socket activation (systemd holds the socket so connections queue). Use Type=exec at minimum, which at least confirms the binary execve()'d.

3. You put StartLimitBurst in [Service] and the service still crash-loops forever. Why? StartLimitIntervalSec and StartLimitBurst must be in the [Unit] section in modern systemd; in [Service] they are silently ignored, so the crash-loop breaker does nothing. Move them to [Unit], pair with Restart=on-failure, and confirm placement with systemctl cat.

4. What does the [Install] section do, and what happens without it? [Install] is inert while the service runs; it only takes effect at systemctl enable, which reads WantedBy=/RequiredBy= and creates the symlink that wires the unit into a boot target. Without [Install], enable has nothing to do and the unit never starts at boot — even though start works right now. systemctl is-enabled returns static instead of enabled.

5. How is a systemd timer better than cron, concretely? Timers give per-unit journald logging (no manual redirection), Persistent=true catch-up for runs missed while the machine was off, RandomizedDelaySec= jitter to avoid fleet stampedes, dependency ordering (After=/Wants=), full cgroup resource control and sandboxing on the triggered service, and visible history via systemctl status/list-timers. The service the timer triggers is just a normal .service, so it inherits every [Service] capability.

6. Explain MemoryHigh vs MemoryMax. MemoryMax is the hard ceiling — exceeding it invokes the cgroup OOM killer, which reaps a process. MemoryHigh is a soft throttle — past it the kernel aggressively reclaims and stalls the cgroup but does not kill. Set MemoryHigh below MemoryMax so the workload can shed memory under pressure before the OOM killer fires — critical for GC’d runtimes like the JVM.

7. What is the difference between CPUQuota and CPUWeight? CPUQuota is an absolute ceiling on CPU time (150% = 1.5 cores) enforced even on an idle box. CPUWeight (1–10000, default 100) is relative and only matters under contention — it divides contended CPU proportionally among competing cgroups and is invisible when the CPU isn’t saturated. Use CPUQuota for a hard cap, CPUWeight for prioritisation.

8. How do you customise a package-shipped unit so your change survives an upgrade? Use a drop-in: systemctl edit <unit> creates /etc/systemd/system/<unit>.d/override.conf, merged on top of the base unit, which stays untouched by the package. Never copy the unit into /etc/systemd/system (that shadows it and can drift), and never edit /usr/lib/systemd/system (upgrades revert it). For list-valued keys like ExecStart=, empty-reset first (ExecStart=) then set the new value, or you get two entries.

9. Name four high-value hardening directives and what each does. NoNewPrivileges=true (process/children can never gain privileges via setuid/fscaps); ProtectSystem=strict (whole filesystem read-only in the service’s namespace, with ReadWritePaths= exceptions); CapabilityBoundingSet= (drops every Linux capability not listed); SystemCallFilter=@system-service (seccomp allowlist blocking exotic/dangerous syscall groups). DynamicUser=yes is arguably the strongest single directive. Always test the running service and check systemd-analyze security.

10. A Type=forking daemon shows as failed or with the wrong PID. What’s missing? PIDFile=. A forking daemon double-forks and the parent exits, so systemd needs the PID file to know which surviving process is the real daemon. Without it, systemd tracks the wrong process (or none). Set PIDFile= to where the daemon writes it — or, better, switch the daemon to Type=notify/Type=exec if it supports foreground operation.

11. Your service starts fine but dies on the first real request after you added SystemCallFilter. How do you diagnose and fix it? The seccomp filter is blocking a syscall the service needs at runtime (not at startup). The journal (journalctl -u <u>) shows SIGSYS or Operation not permitted. Remove the last directive to confirm it’s the cause, then add back the specific syscall group the service needs (e.g. SystemCallFilter=@system-service @pkey). The lesson: hardening must be tested against the exercised service, not just “it started.”

12. What does network-online.target guarantee that network.target does not, and how do you use it? network.target is only an ordering point for the networking stack coming up — it does not mean the network is configured. network-online.target blocks until an address is actually configured and routable, but only if the relevant wait-online service (systemd-networkd-wait-online/NetworkManager-wait-online) is enabled. A service needing a routable IP at start uses both After=network-online.target and Wants=network-online.target.

These map to the LFCS and RHCSA/RHCE objectives (manage services with systemd, control boot targets, schedule with timers, and — RHCE — configure resource limits and analyse boot), and to the LPIC-1/LPIC-2 system-startup and service-management topics. The cgroup and hardening material is directly relevant to the CKA/CKS worldview even though Kubernetes abstracts it, because the kubelet, containerd, and node-level DaemonSets all run as systemd units you must debug. A compact mapping for revision:

Question theme Primary cert Objective area
Unit types, [Unit]/[Service]/[Install] RHCSA / LFCS Manage systemd services and units
Dependencies, ordering, targets, boot RHCSA / LPIC-1 Boot the system into different targets
Timers vs cron, OnCalendar RHCSA / LFCS Schedule tasks
cgroup resource control, slices RHCE / LFCS Control resource utilisation
Sandboxing, capabilities, seccomp (Security tracks) Harden services; least privilege
journald, boot analysis RHCSA / LPIC-1 Analyse and manage system logs

Quick check

  1. You edit a unit file and systemctl restart it, but the change doesn’t take effect. What one command did you forget, and why is it needed?
  2. A service ordered After= yours connects to yours and gets “connection refused” on some boots but not others. What Type= is your service almost certainly using, and what do you switch it to?
  3. True or false: putting StartLimitBurst=5 in the [Service] section gives your service a crash-loop breaker.
  4. You migrated a nightly backup from cron to a systemd timer, but on a laptop that sleeps overnight the backup silently never runs. Which one [Timer] directive did you omit?
  5. What is the difference between MemoryHigh and MemoryMax, and why set MemoryHigh lower?

Answers

  1. sudo systemctl daemon-reload. systemd parses unit files into memory and runs from that cache; without a reload it keeps using the old parsed unit, so your edit is ignored until you reload (then restart).
  2. Your service is almost certainly Type=simple, which reports “started” the instant it forks — before it has bound its listener — so the dependent races it. Switch to Type=notify (with the service calling sd_notify(READY=1) when it can serve) or use socket activation so connections queue instead of failing.
  3. False. StartLimitIntervalSec/StartLimitBurst must be in the [Unit] section; in [Service] they are silently ignored and the service can crash-loop forever. Pair them with Restart=on-failure.
  4. Persistent=true. It records the last run on disk so a run missed while the machine was off fires shortly after the next boot (the cron-with-anacron behaviour). Without it, a missed scheduled time is simply skipped.
  5. MemoryMax is the hard limit — breaching it triggers the cgroup OOM killer, which reaps a process. MemoryHigh is a soft throttle — past it the kernel aggressively reclaims and stalls the cgroup but does not kill. Set MemoryHigh below MemoryMax so the workload (especially a GC’d runtime) can shed memory before the OOM killer ever fires.

Glossary

Next steps

You can now write, harden, resource-limit, schedule, and debug systemd units from a blank file. Build outward:

systemdLinuxcgroupsjournaldtimerssandboxingBashReliability
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading