A cron job silently stopped running for three weeks and nobody knew until the backups were missing. systemd timers give you the logging and status cron never did.
The backup cron job had been failing silently for three weeks. The script exited non-zero because a mount wasn't ready, cron dutifully tried to email the output to a local mailbox nobody reads, and the job just kept "running" on schedule while doing nothing. We found out when we needed a restore. That's the failure mode cron is built for: fire and forget, and the forgetting is the problem. We moved our scheduled jobs to systemd timers, and the difference is mostly about knowing whether a job actually ran.
A systemd timer is a pair: a .service that does the work and a .timer that says when. That split feels like overhead compared to one crontab line, but it's what buys you the observability.
# /etc/systemd/system/db-backup.service
[Unit]
Description=Nightly database backup
Wants=network-online.target
After=network-online.target postgresql.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/db-backup.sh
User=backup
Nice=10
IOSchedulingClass=idle
# /etc/systemd/system/db-backup.timer
[Unit]
Description=Run db-backup nightly
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
Enable it with systemctl enable --now db-backup.timer. The OnCalendar syntax replaces cron's five-field line; *-*-* 02:30:00 is 2:30am daily. You can express weekly (Mon *-*-* 06:00:00), or "every 15 minutes" (OnCalendar=*:0/15), and check your expression with systemd-analyze calendar 'Mon *-*-* 06:00:00', which prints the next elapse time so you don't guess.
First, missed runs. Persistent=true means if the machine was off at 2:30am, the job runs at next boot instead of being silently skipped. Cron just misses it. For a laptop or a VM that isn't always on, this alone is decisive.
Second, thundering herds. RandomizedDelaySec=300 jitters the start by up to five minutes. Run the same cron line on 200 hosts and they all hit your backup target at exactly 2:30:00; timers spread the load. Cron has no equivalent.
Third, and the big one, status and logs. Every run's output goes to the journal, tagged, with exit codes:
$ systemctl status db-backup.timer
● db-backup.timer - Run db-backup nightly
Active: active (waiting)
Trigger: Sat 2026-07-04 02:30:00 UTC; 9h left
$ journalctl -u db-backup.service --since today
Jul 04 02:31:12 host db-backup.sh[4021]: dumped 2.4GB in 41s
Jul 04 02:31:53 host systemd[1]: db-backup.service: Succeeded.
systemctl list-timers shows every timer, its last run, and its next run in one screen. When the backup fails, the service enters a failed state you can alert on, instead of an email into the void. Wire OnFailure=notify-oncall@.service into the unit and a failed job pages you.
Our original silent failure was a mount not being ready. Cron has no concept of "wait for the mount". systemd does: After= and Requires= express real ordering against other units. The backup only starts after postgresql.service is up and network-online.target is reached. If the dependency isn't met, the job fails loudly rather than running against a half-ready system. You can also gate on a mount unit directly:
[Unit]
RequiresMountsFor=/mnt/backups
Now the service won't even attempt to run if /mnt/backups isn't mounted, which is exactly the check the old cron job lacked.
It's more files and more ceremony. A one-off 0 2 * * * cron line becomes two units and a daemon-reload. For a throwaway personal script, that's genuine friction, and cron is still fine. Timers also don't email output by default (you read the journal instead), so if your whole ops workflow is built on cron mail, budget time to change habits. And OnCalendar syntax, while more readable, is different enough from cron's five fields that you'll reach for systemd-analyze calendar a few times before it sticks.
For anything whose failure matters (backups, cert renewals, data syncs, cleanup that prevents disk-full), migrate it to a systemd timer. The Persistent, RandomizedDelaySec, dependency ordering, and journal integration turn "the job silently stopped and we found out during a restore" into "the unit went failed and paged us at 2:31am". For trivial personal cron lines where nobody cares if a run is missed, leave them in cron; the two-file overhead isn't worth it. The dividing line is whether you'd want to know when the job breaks. If yes, it belongs in a timer.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Both put SQLite near your users, but they solve replication and write latency very differently. We ran the same schema on both for a month and picked one.
We had long-lived AWS keys sitting in a datacenter we don't own. IAM Roles Anywhere let us delete every one of them. Here's the real setup.
Explore more articles in this category
We had 140 engineers with 300 static public keys scattered across authorized_keys files nobody could audit. Moving to SSH certificates with short TTLs made access reviewable again.
Our proxy topped out at 40k connections while the CPU sat half-idle. The bottleneck was kernel defaults tuned for 2009, not the hardware.
When a service is slow and every dashboard looks green, bpftrace lets you watch the kernel directly. These one-liners found our tail latency.
Evergreen posts worth revisiting.