systemd Timers vs Cron: Migrating Scheduled Jobs the Right Way
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.
Key takeaways
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.
systemd Timers vs Cron: Migrating Scheduled Jobs the Right Way#
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 timer is two units#
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.
The three things cron can't do#
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.
Dependency ordering that actually works#
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.
What you give up#
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.
The call we'd make#
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.
For the broader picture of which categories of jobs we deliberately left on cron even after a year of migrating, see systemd timers vs cron: when cron still wins.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Argo CD ApplicationSets: Managing Many Clusters Without Copy-Paste
Twenty-three clusters, one app, and a folder of near-identical Application YAMLs that drifted constantly. ApplicationSets killed the copy-paste and the drift.
Semantic Caching for LLM Apps: Cutting Cost on Repeated Queries
Users kept asking the same questions in slightly different words, and we paid full price every time. Semantic caching cut our LLM bill by a third.
More from Linux
Explore more articles in this category
CERN Left Red Hat for Debian: The CPU Baseline Lesson
CERN is moving accelerator control computers to Debian because RHEL raised its minimum CPU level. Check your own fleet's x86-64-vN support before the next OS upgrade does it for you.
ext4 vs XFS vs Btrfs: Choosing a Filesystem for a Server
The default filesystem your distro picks is not always the right one for your workload. Here is what actually differs and when each one wins.
journald Log Management: Retention, Filtering, and Forwarding
journald is the default log sink on every systemd distro, and most of it runs on defaults nobody chose. Here is how to actually control it.
You might have missed
Evergreen posts worth revisiting.