Task Automation & Service Orchestration: Systemd Units, .timer Schedules, Sandboxing & Cron
Executive Summary: Legacy
cronjobs run with a stripped environment (PATH=/usr/bin:/bin), silently discardstderrif local MTA mail delivery is unconfigured, cannot declare network/mount dependencies (After=network-online.target), and cannot enforce cgroup resource caps or filesystem sandboxing. This architectural guide covers production.serviceand.timersystemd units,systemd-analyze calendarvalidation, cgroups v2 resource controls, systemd security sandboxing (systemd-analyze security), and defensivecron+flockpatterns where POSIX cron remains required.
-
Systemd
.timerOver Legacy Cron
Replace silent cron failures with persistent, jitter-aware (
RandomizedDelaySec), journald-logged systemd timers andOnFailure=alert hooks. -
Custom
.serviceLifecycle Design
Architect
Type=simple,Type=notify, andType=oneshotservices with automatic crash recovery (Restart=on-failure) and dependency ordering. -
Systemd Unit Sandboxing
Isolate services using
DynamicUser=yes,ProtectSystem=strict,PrivateTmp=yes,NoNewPrivileges=yes, andCapabilityBoundingSet=. -
Overlap-Safe Cron (
flock)
Prevent overlapping cron stampedes using kernel advisory locks (
flock -n) and explicitPATH/SHELLheaders in/etc/cron.d/.
1. Architecture: Systemd .timer + .service vs. Legacy crond
In systemd, scheduling is cleanly decoupled into two paired unit files in /etc/systemd/system/:
1. <name>.timer: Defines when the job triggers (OnCalendar=, OnBootSec=, Persistent=true).
2. <name>.service: Defines what executes (ExecStart=), under which user/cgroup (User=, MemoryMax=), inside what security sandbox (ProtectSystem=strict), and what happens if it fails (OnFailure=notify-failure@%n.service):
%%{init: {'flowchart': {'wrappingWidth': 600, 'subGraphTitleMargin': {'top': 15, 'bottom': 15}}}}%%
flowchart TB
subgraph TimerUnit["db-backup.timer"]
Cal["OnCalendar=*-*-* 02:00:00<br/>Persistent=true<br/>RandomizedDelaySec=15m"]
end
subgraph ServiceUnit["db-backup.service (Type=oneshot)"]
Sandbox["Cgroup v2 & Namespace Sandbox<br/>MemoryMax=2G, ProtectSystem=strict"]
Exec["ExecStart=/usr/local/bin/backup.sh<br/>Logs -> systemd-journald"]
end
subgraph AlertUnit["OnFailure=notify-ops@%n.service"]
Webhook["Dispatch Matrix / ntfy / Kuma<br/>Alert with Exit Status"]
end
Cal -->|"Triggers Unit"| Sandbox
Sandbox --> Exec
Exec -.->|"Non-Zero Exit Code"| Webhook
Systemd Timers vs. Legacy Cron Comparison
| Engineering Feature | Systemd .timer + .service |
Legacy crontab / /etc/cron.d |
|---|---|---|
| Logging & Observability | Every line of stdout/stderr structured in journalctl -u <unit> with exit code & runtime metrics. |
Silently discarded unless local postfix/msmtp MTA is configured to deliver local mail. |
| Overlap Prevention | Built-in Native: A .timer will never spawn a second instance of its .service if the previous run is still active. |
Spawns duplicate overlapping processes every minute unless manually wrapped in flock -n. |
| Missed Run Catch-Up | Built-in via Persistent=true (if server was powered off at 02:00, runs immediately upon boot). |
Requires external anacron daemon (daily/weekly granularity only). |
| Manual Test Execution | Run sudo systemctl start <name>.service at any time in the exact production environment. |
Must copy-paste command manually into an interactive shell (different environment variables!). |
2. Authoring a Hardened Production .service and .timer Pair
Always place custom or overridden unit files inside /etc/systemd/system/ (never edit vendor files inside /lib/systemd/system/, as package upgrades will overwrite them).
[Unit]
Description=Encrypted Off-Site Restic Backup Synchronization
Documentation=https://nejat-hakan.de/
After=network-online.target local-fs.target
Wants=network-online.target
OnFailure=notify-failure@%n.service
[Service]
Type=oneshot
User=root
Group=root
EnvironmentFile=/etc/restic/env.conf
ExecStart=/usr/local/bin/restic-backup-job.sh
# --- Resource Scheduling & Cgroup v2 Caps ---
Nice=15
IOSchedulingClass=best-effort
IOSchedulingPriority=6
CPUQuota=150%
MemoryMax=2G
# --- Systemd Namespace & Filesystem Sandboxing ---
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/cache/restic /var/log
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
[Unit]
Description=Nightly Schedule for Encrypted Off-Site Restic Backup
[Timer]
# Run daily at 02:15:00 with up to 10 minutes of random jitter to prevent I/O storms
OnCalendar=*-*-* 02:15:00
RandomizedDelaySec=10m
Persistent=true
Unit=restic-sync.service
[Install]
WantedBy=timers.target
[Unit]
Description=Out-of-Band Alert Notification for Failed Unit %i
[Service]
Type=oneshot
ExecStart=/usr/bin/curl -fsS -H "Title: Systemd Failure on %H" -H "Priority: high" \
-d "Unit %i failed on host %H. Inspect via: journalctl -u %i -n 50" \
https://ntfy.nejat-hakan.de/infra-alerts
3. Validating Calendar Expressions, Sandboxing & Activating Units
Before enabling a timer, verify your OnCalendar= expression using systemd-analyze calendar (which calculates the exact next 5 trigger timestamps) and audit the unit's security exposure score:
# 1. Verify unit file syntax and test OnCalendar schedule calculation
systemd-analyze verify /etc/systemd/system/restic-sync.*
systemd-analyze calendar --iterations=5 "*-*-* 02:15:00"
# 2. Audit the security sandboxing score of any service (Target: < 2.5 SAFE)
systemd-analyze security restic-sync.service
# 3. Reload systemd manager configuration, enable the .timer, and test-run the .service once
sudo systemctl daemon-reload
sudo systemctl enable --now restic-sync.timer
sudo systemctl start restic-sync.service
# 4. Inspect all active timers with next/last execution timestamps
systemctl list-timers --all
4. Defensive Cron Engineering (flock, PATH & /etc/cron.d/)
When working on legacy appliances or POSIX systems where cron is mandatory, avoid user crontab -e for infrastructure tasks. Instead, deploy version-controlled drop-in files in /etc/cron.d/ (chmod 0644, owned by root:root) with an explicit SHELL, PATH, and flock -n lockfile wrapper so a slow job can never spawn overlapping runaway instances:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
# <min> <hour> <dom> <month> <dow> <user> <flock wrapper + command + syslog logger>
15 3 * * * root /usr/bin/flock -n /run/cert-sync.lock /usr/local/bin/sync-certs.sh 2>&1 | /usr/bin/logger -t cert-sync