Cron has been Linux’s go-to scheduler for fifty years. It works. But it also has blind spots: no logging beyond mail spool, no dependency chaining, no catch-up for missed runs, and no integration with the rest of the system’s init machinery.
Systemd timers solve all of this natively. If your homelab runs systemd — and any modern Linux distribution does — you already have a better scheduler installed. This guide covers when and why to replace cron with systemd timers, the exact unit file patterns you need, and real-world homelab examples you can copy today.
Why Systemd Timers Beat Cron in a Homelab
Cron is simple. A one-liner in a crontab and you are done. But in a homelab with containers, ZFS datasets, Proxmox nodes, and MikroTik backups, your tasks accumulate fast. The cracks start to show:
| Capability | cron | systemd timer |
|---|---|---|
| Logging | Mail spool or syslog | journalctl with structured metadata |
| Missed-run catch-up | Task is skipped | Persistent=true fires on next boot |
| Random delay | Manual sleep hack | RandomizedDelaySec= built-in |
| Dependencies | None | After=, Requires=, BindsTo= |
| Sandboxing | None | ProtectHome=, PrivateTmp=, etc. |
| Failed-run notification | Mail on stderr | OnFailure= unit chaining |
| Per-user scheduling | crontab -e per user |
--user systemd instance |
| Timezone-aware | Requires CRON_TZ= |
Respects system timezone |
| Verification | Parse visually | systemd-analyze calendar |
For a homelab running 10-20 recurring tasks — container restarts, zfs snapshot rotation, log cleanup, health pings, backup syncs — systemd timers provide observability and reliability that cron simply cannot match.
Anatomy of a Systemd Timer
A timer always comes in a pair: a .timer unit defines when to run,
and a .service unit defines what to run. The timer activates the
service, the service does the work, and systemd tracks both.
Service Unit
|
|
Timer Unit
|
|
Enable and Start
|
|
Output:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Mon 2026-05-26 03:00:00 EDT 13h left Sun 2026-05-25 03:17:42 EDT 9h ago docker-cleanup.timer docker-cleanup.service
Compare that to crontab -l showing 0 3 * * * /usr/local/bin/docker-cleanup.sh.
The timer output tells you the next run, last run, and how long ago
it ran — all without grepping logs.
OnCalendar Syntax — The Replacement for Cron Expressions
Systemd’s OnCalendar syntax is more readable than cron but follows
its own pattern: DOW YYYY-MM-DD HH:MM:SS. Missing components act
as wildcards.
Common Patterns
| Schedule | OnCalendar value | Equivalent cron |
|---|---|---|
| Every day at 2:30 AM | *-*-* 02:30:00 |
30 2 * * * |
| Every 15 minutes | *:0/15 |
*/15 * * * * |
| Every hour | hourly or *:0 |
0 * * * * |
| Daily | daily or *-*-* 00:00:00 |
0 0 * * * |
| Weekly (Monday midnight) | Mon *-*-* 00:00:00 |
0 0 * * 1 |
| First day of month 6 AM | *-*-1 06:00:00 |
0 6 1 * * |
| Every 30 minutes | *:0/30 |
*/30 * * * * |
| Weekdays at 8 PM | Mon..Fri 20:00:00 |
0 20 * * 1-5 |
Validate Before Deploying
|
|
Output:
Original form: Mon..Fri 20:00:00
Normalized form: Mon..Fri 20:00:00
Next elapse: Mon 2026-05-25 20:00:00 EDT
(in UTC): Mon 2026-05-25 20:00:00 EDT
From now: 6h left
Always run systemd-analyze calendar before deploying a new timer.
A typo in the DOW portion does not error — it silently produces an
empty schedule and the job never runs.
Monotonic Timers — Run Relative to Boot or Last Run
Not every task needs a wall-clock schedule. Some tasks should run relative to system state:
|
|
This is perfect for:
- Health pings to a monitoring service after the machine boots
- Temp file cleanup that should run periodically but has no specific preferred wall time
- Docker container restart policies that need retry logic outside Docker’s built-in restart
Monotonic timers do not accept OnCalendar — the two families are
mutually exclusive in a single timer unit. Use monotonic when the
exact wall clock is irrelevant; use OnCalendar when it matters.
Persistent Timers — Catch Up on Missed Runs
The Persistent=true flag is the biggest practical advantage over
cron for homelab machines that may not run 24/7.
|
|
With cron, if your machine was off at 3 AM, that day’s backup simply never runs. With a persistent systemd timer, the service fires immediately on the next boot — even if the machine was off for three days, it catches up on the first boot after the outage.
Pair Persistent=true with RandomizedDelaySec to avoid hammering
resources when multiple persistent timers trigger simultaneously
after a reboot:
|
|
Real-World Homelab Examples
1. ZFS Snapshot Rotation
|
|
|
|
The script (/usr/local/bin/zfs-snapshot-rotate.sh):
|
|
2. Docker Prune with Health Notification
|
|
|
|
The Wants=docker.service dependency ensures the Docker daemon is
running before the prune fires. Without this, the exec would fail
silently.
3. Proxmox PBS Garbage Collection
For Proxmox Backup Server, garbage collection reclaims space from pruned backup chunks. A weekly GC keeps your datastore healthy:
|
|
|
|
4. Homelab Health Ping
|
|
|
|
This runs 2 minutes after boot, then every 5 minutes. Uptime Kuma (or your monitoring tool of choice) alerts you if the ping stops — dead simple, zero agent overhead.
5. Off-Site Backup Sync
|
|
|
|
The After=network-online.target ensures the network is actually
up before the backup starts — something cron users typically hack
with a sleep 30 in the script.
Hardening Timer Service Units
Since every timer pair includes a service unit, you get systemd’s full sandboxing arsenal for free. These are the most useful options for homelab timer services:
| Option | Effect |
|---|---|
ProtectSystem=strict |
Read-only /usr, /etc (except whitelisted) |
ProtectHome=true |
/root, /home are inaccessible |
PrivateTmp=true |
Isolated /tmp per invocation |
NoNewPrivileges=true |
Prevents privilege escalation |
CapabilityBoundingSet= |
Drops all kernel capabilities |
SystemCallFilter=@system-service |
Restricts allowed syscalls |
MemoryMax=256M |
Limits memory usage |
TasksMax=32 |
Limits number of tasks/processes |
ReadWritePaths= |
Explicit writable paths, everything else read-only |
Example for a script that only needs to write to one backup directory:
|
|
User Timers — Per-User Scheduling
Not all homelab automation runs as root. Systemd supports per-user timers that run under your user’s systemd instance:
|
|
Enable lingering so user timers survive logout:
|
|
User timers are ideal for:
- User-level backups (restic repos, git repos)
- Updating personal dotfiles or hosts files
- Emailing reports or syncing calendars
- Any automation that should not run as root
Monitoring and Debugging
List All Active Timers
|
|
Check Timer Status
|
|
Journal Logs for the Last Run
|
|
Force a Run (Manual Trigger)
|
|
Running the service unit directly bypasses the timer schedule but still logs through journald — useful for testing.
Test the Timer Schedule
|
|
Check for Failures
|
|
Converting Existing Cron Jobs
Migration script to convert crontab entries to systemd timer units is straightforward for simple cases:
|
|
This handles the common case. For complex schedules with step values
(*/15), you will need to adjust the OnCalendar expression manually.
When to Still Use Cron
Systemd timers are not a universal replacement. Keep cron for:
-
Minimal containers — Alpine-based Docker images do not run systemd. Use the container’s own cron or supervisord.
-
Legacy systems — Older distributions with sysvinit or systemd < v236 lack timer features like
RandomizedDelaySec. -
Simple one-off scripts — If you need literally one line and will never need logging, cron’s
@rebootand one-liner syntax is faster to type. -
Non-systemd environments — WSL1, some embedded Linux, or systems with systemd masked.
For everything else in a modern homelab — backups, pruning, healthchecks, sync jobs, snapshot rotation — systemd timers provide better observability, reliability, and security with no external dependencies.
Summary
Systemd timers replace cron with a first-class scheduling system that integrates with the rest of your Linux stack:
- Each timer needs two files: a
.timerfor the schedule and a.servicefor the command — enable both withsystemctl enable --now. - Use
OnCalendarfor wall-clock schedules andOnBootSec/OnUnitActiveSecfor monotonic intervals. - Always add
Persistent=truefor tasks that should catch up after downtime (backups, pruning). - Add
RandomizedDelaySecto prevent multiple timers from hammering resources simultaneously after boot. - Sandbox service units with
ProtectSystem,PrivateTmp, andNoNewPrivileges— these apply to any systemd unit, not just timers. - Use
journalctl -u servicename.servicefor structured logging — no more grepping mail spool or syslog files. - Validate schedules with
systemd-analyze calendarbefore deploying — catch typos before they cause silent failures.
A homelab running a dozen timer-managed tasks — ZFS snapshots, Docker pruning, PBS garbage collection, health pings, and off-site backups — runs more reliably and observably than the same tasks managed by cron, with no additional software to install.