The Cron Problem Nobody Talks About
You’ve got a cron job. It’s been running for three years. You haven’t checked it once. That’s either the best or worst situation you could be in — and you won’t know which until something breaks.
Here’s the thing: cron is old. Not “vintage” old. Not “still good” old. It’s Ken Thompson-in-1974 old. And while it’s survived this long because it works, it also survived this long by doing one thing and refusing to do anything else. No logging unless you set it up. No dependencies between jobs. No way to catch up if the system was offline. No way to test a cron expression without waiting for it to run.
You could stare at a cron expression like 0 2 * * 0 /usr/local/bin/backup.sh and still not be 100% sure when it runs. (2 AM every Sunday, by the way. Your 2 AM self will appreciate the comment explaining that.)
Systemd timers are what we built to fix all this. They’re not going away. Every modern Linux distro has them. And if you’re still only using cron, you’re leaving a lot of operational sanity on the table.
Why This Matters (Beyond Hipster Points)
Systemd timers give you:
- Real logging —
journalctl -u myservice.timershows every run, every failure, every second of execution - Dependency ordering — run one timer only after another completes
- Persistent timers — if your server was down when a job should’ve run, systemd catches it up
- Testing —
systemd-analyze calendarvalidates your schedule before deployment - Better error handling — failed runs don’t silently disappear
This isn’t theology. This is “3 AM your monitoring system finds the job didn’t run and you have the logs right there” vs “3 AM you’re SSH’d in running tail -f /var/log/syslog hoping you catch the grep.”
Anatomy of a Systemd Timer
A systemd timer is two files: a .timer unit and a .service unit. They work together like a tag team.
Let’s say you want to back up your database every day at 3 AM.
First, the service file — the actual job:
[Unit]Description=Daily PostgreSQL BackupAfter=network-online.targetWants=network-online.target
[Service]Type=oneshotUser=postgresExecStart=/usr/local/bin/backup-db.shStandardOutput=journalStandardError=journalType=oneshot means “run once and exit” (unlike services that stay running). Everything goes to journal so you can grep it later.
Now the timer:
[Unit]Description=Daily PostgreSQL Backup TimerRequires=backup-db.service
[Timer]OnCalendar=*-*-* 03:00:00Persistent=true
[Install]WantedBy=timers.targetThat’s it. OnCalendar is the schedule. Persistent=true means “if we were offline, run it as soon as we boot.”
Enable and start:
$ sudo systemctl enable backup-db.timerCreated symlink /etc/systemd/system/timers.target.wants/backup-db.timer → /etc/systemd/system/backup-db.timer.
$ sudo systemctl start backup-db.timer
$ sudo systemctl list-timersNEXT LEFT LAST PASSED UNIT ACTIVATESMon 2026-04-14 03:00:00 UTC 10h left Sun 2026-04-13 03:00:13 UTC 52min ago backup-db.timer backup-db.serviceSee? You know exactly when it runs next. And the logs:
$ sudo journalctl -u backup-db.service -n 10Apr 13 03:00:13 prodbox systemd[1]: Starting Daily PostgreSQL Backup...Apr 13 03:00:13 prodbox backup-db.sh[4521]: Backup started: /srv/backups/db-2026-04-13.sql.gzApr 13 03:00:45 prodbox backup-db.sh[4521]: Backup complete: 2.3 GBApr 13 03:00:45 prodbox systemd[1]: Finished Daily PostgreSQL Backup.No wondering. No digging. The truth is right there.
OnCalendar: The Schedule Syntax
OnCalendar is the field that replaces cron. The format is day-of-week year-month-day hour:minute:second.
Common examples:
# Every day at 3 AMOnCalendar=*-*-* 03:00:00
# Every Monday at noonOnCalendar=Mon *-*-* 12:00:00
# Every 15th of the month at 2:30 PMOnCalendar=*-*-15 14:30:00
# First Monday of every month at 9 AMOnCalendar=Mon *-*-1..7 09:00:00
# Every 6 hours (use OnUnitActiveSec for this instead, see below)Here’s the magic: test it without waiting:
$ systemd-analyze calendar '*-*-* 03:00:00' Original form: *-*-* 03:00:00Normalized form: *-*-* 03:00:00 Next elapse: Tue 2026-04-14 03:00:00 UTC (in 10h 24min 12s)You can instantly validate the schedule. No guessing.
For relative timers (every N seconds/minutes/hours), use OnBootSec or OnUnitActiveSec:
[Timer]# Run 5 minutes after bootOnBootSec=5min
# Run every hour (300 seconds after the last run)OnUnitActiveSec=1hPersistent Timers and Missed Runs
Say your server rebooted during the 3 AM backup. With cron, that backup just… doesn’t happen. Nobody notices until Thursday when your restore test fails.
With Persistent=true:
[Timer]OnCalendar=*-*-* 03:00:00Persistent=trueSystemd tracks the last run time. On boot, if a scheduled time was missed, it runs the job immediately. Your backup catches up.
Real-World Example: Certificate Renewal
Here’s a practical one — renewing ACME certificates (without using a renewal daemon):
[Unit]Description=Renew ACME CertificatesAfter=network-online.targetWants=network-online.target
[Service]Type=oneshotExecStart=/opt/acme/renew.shStandardOutput=journalStandardError=journalOnFailure=notify-admins@%n.serviceThe timer:
[Unit]Description=Renew ACME Certificates WeeklyRequires=renew-certs.service
[Timer]OnCalendar=Sun *-*-* 02:00:00Persistent=trueRandomizedDelaySec=1h
[Install]WantedBy=timers.targetRandomizedDelaySec=1h spreads out the load if you’re running this on 1000 servers — they won’t all hammer your ACME server at 2 AM.
The OnFailure= handler (in the service) can send you a Slack message if it breaks.
When Cron Is Still Fine
Systemd timers aren’t a cult. Cron is still good for:
- Simple, one-off jobs in a personal lab
- Infrequent tasks (monthly cleanup that never fails)
- When you don’t care about logging (and shouldn’t feel guilty about it)
If your job is literally 0 1 * * 6 root rm -rf /tmp/old-* and you’ve never thought about it since 2015, leave it. The oxygen spent migrating it to systemd is better spent elsewhere.
But if your job is production infrastructure? Cron is a liability. Its silence is a feature until the moment it’s a catastrophe.
Converting Cron to Systemd: Step by Step
Say you’ve got:
0 2 * * * /usr/local/bin/clean-logs.sh- Create the service file (use the script as-is):
[Unit]Description=Clean Old LogsAfter=network-online.target
[Service]Type=oneshotExecStart=/usr/local/bin/clean-logs.shStandardOutput=journalStandardError=journal- Create the timer:
[Unit]Description=Clean Old Logs DailyRequires=clean-logs.service
[Timer]OnCalendar=*-*-* 02:00:00Persistent=true
[Install]WantedBy=timers.target-
Remove the cron entry:
crontab -e, delete the line. -
Enable and start:
sudo systemctl daemon-reloadsudo systemctl enable clean-logs.timersudo systemctl start clean-logs.timer- Test:
sudo systemctl start clean-logs.servicesudo journalctl -u clean-logs.service -fDone. Now you’ve got logging, dependency ordering, persistent runs, and a way to test everything. Your future self at 3 AM will send thank-you notes.