Quick summary: systemd timers do everything cron does and more, with proper logging via journalctl, restart-on-failure, dependency management, sandboxing, and randomized delay support that prevents the "thundering herd at midnight" problem. The migration is mechanical (every cron entry maps to a timer + service pair) but worth doing for any production system. This guide walks through the syntax mapping, the calendar expression language that replaces cron's compact form, persistence behavior that handles missed runs correctly, randomization patterns, debugging via journalctl, and the operational pitfalls to avoid during migration.
Why Migrate?
cron is a 1970s tool that has aged remarkably well. It still works. The reasons to replace it in 2026:
- Logging. cron sends output to email (which most systems do not have configured) or /dev/null. systemd timers log to journalctl, queryable like any other unit.
- Failure visibility. A failed cron job is silent unless email works. A failed systemd unit shows up in
systemctl --failed, can trigger notifications, and can restart automatically. - Sandboxing. cron runs jobs as the user with full privileges. systemd units can drop privileges, restrict syscalls, isolate filesystem access β the same sandboxing options we covered in the systemd unit tutorial apply to timer-launched jobs.
- Persistence and randomization. Missed cron jobs (system was off at the scheduled time) are simply skipped. systemd timers with
Persistent=truerun on next boot. Randomized delay prevents the thundering herd at midnight. - Dependency management. cron jobs run in isolation. systemd units can declare dependencies β "run this after the database is up" β that cron has no equivalent for.
- Standardized lifecycle.
systemctl status myjob.timer,systemctl list-timers,journalctl -u myjob.service. Same tooling as every other systemd unit.
The Mechanical Translation
Every cron entry maps to two systemd files: a service unit (what to run) and a timer unit (when to run it). They have the same name with different extensions.
Example: a daily backup script
cron version:
# crontab -e
0 3 * * * /usr/local/bin/backup.sh
systemd version:
# /etc/systemd/system/backup.service
[Unit]
Description=Daily backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=15min
[Install]
WantedBy=timers.target
Enable the timer (not the service):
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer
The service definition pattern is the same as any other systemd service β Type=oneshot is the key, marking it as a "run and exit" unit rather than a long-running daemon.
The Calendar Expression Language
systemd's OnCalendar= uses a more verbose but more flexible syntax than cron's five-field format.
Basic patterns
| cron | systemd OnCalendar | Meaning |
|---|---|---|
* * * * * | *-*-* *:*:00 | Every minute (more or less) |
0 * * * * | hourly | Every hour at :00 |
0 0 * * * | daily | Every day at midnight |
0 0 * * 0 | weekly | Every Sunday at midnight |
0 0 1 * * | monthly | First of every month at midnight |
0 3 * * * | *-*-* 03:00:00 | Daily at 3am |
0 3 * * 1 | Mon *-*-* 03:00:00 | Mondays at 3am |
*/15 * * * * | *-*-* *:00/15:00 | Every 15 minutes |
0 9-17 * * 1-5 | Mon..Fri *-*-* 09..17:00:00 | Hourly on business hours, weekdays |
Test your calendar expression
systemd-analyze calendar '*-*-* 03:00:00'
# Returns:
# Original form: *-*-* 03:00:00
# Normalized form: *-*-* 03:00:00
# Next elapse: Tue 2026-05-27 03:00:00 UTC
# (in UTC): Tue 2026-05-27 03:00:00 UTC
# From now: 14h left
systemd-analyze calendar --iterations=5 'Mon..Fri *-*-* 09:00:00'
# Shows the next 5 fire times
This is genuinely useful β testing complex schedules before deployment catches the "I thought that meant Monday but it means every weekday" type of bug.
The Three Killer Features
1. Persistent=true
If the system was off when the timer should have fired, the job runs on next boot. cron simply skips missed runs.
[Timer]
OnCalendar=daily
Persistent=true
This matters most for daily/weekly/monthly maintenance jobs on systems that are not always on (laptops, intermittently-powered edge devices, dev VMs).
2. RandomizedDelaySec
Add up to N seconds/minutes/hours of randomization to the firing time. Prevents the "100 servers all hit the API at exactly midnight" problem.
[Timer]
OnCalendar=daily
RandomizedDelaySec=30min
The randomization is per-host but stable for that host (using the machine ID as seed). One specific host will fire at a consistent randomized time; the fleet spreads out.
3. OnBootSec / OnActiveSec / OnUnitActiveSec
Relative timers based on system events rather than calendar:
[Timer]
OnBootSec=15min # 15 minutes after boot
OnUnitActiveSec=1h # 1 hour after the previous run completed
OnUnitActiveSec is particularly useful β it gives "every hour, but only after the previous run finishes" semantics that cron cannot express.
Real-World Patterns
Database backup with proper logging and notification
# /etc/systemd/system/db-backup.service
[Unit]
Description=PostgreSQL nightly backup
After=postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
User=postgres
EnvironmentFile=/etc/db-backup.env
ExecStart=/usr/local/bin/db-backup.sh
# Notification on failure
OnFailure=notify-failure@%n.service
# Sandboxing
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/backup
# /etc/systemd/system/db-backup.timer
[Unit]
Description=Schedule nightly DB backup
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=30min
[Install]
WantedBy=timers.target
# /etc/systemd/system/notify-failure@.service
[Unit]
Description=Notify on failure of %i
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-slack.sh "FAILED: %i"
The OnFailure pattern triggers a Slack notification (or PagerDuty, or whatever) whenever the backup fails. cron has no equivalent β you have to build it yourself with wrapper scripts.
Hourly job that takes longer than an hour sometimes
[Timer]
OnUnitActiveSec=1h # Run again 1 hour after last run completed
OnBootSec=10min # Initial run 10 minutes after boot
Behavior: first run 10 minutes after boot, then 1 hour after that run completes (whether the run took 5 minutes or 2 hours). No overlapping runs ever. cron's 0 * * * * would happily start a second run while the first is still going.
Job that should not run during business hours
[Timer]
OnCalendar=Mon..Fri *-*-* 19..06:00:00
# Or, equivalently
OnCalendar=Mon..Fri *-*-* 19,20,21,22,23,00,01,02,03,04,05,06:00:00
Fires hourly outside business hours. The 19..06 range syntax wraps around midnight cleanly.
Migration Strategy
For a server with a populated crontab, the realistic migration sequence:
- Inventory.
crontab -lfor each user;cat /etc/crontab /etc/cron.d/*for system jobs. Document each one. - Convert one at a time. Don't try to convert everything in one big change. One job per change-set, tested individually.
- Run in parallel during transition. Keep the cron entry and add the timer; verify both fire on the same schedule, manually disable cron once confidence is established.
- Validate logs. After each conversion, verify
journalctl -u myjob.serviceshows the expected output. - Remove cron entries. Once all converted,
crontab -ras the affected users (or remove from /etc/crontab). - Update runbooks. Operational documentation that says "check the cron log" needs updating to "check journalctl -u jobname.service".
For a typical server with 5-15 cron entries, the conversion takes 2-4 hours of careful work. For a fleet of similar servers, build the timer files into your config-management baseline and roll them out as a normal config change.
Debugging When Things Go Wrong
The standard sequence:
# Is the timer enabled and active?
systemctl status myjob.timer
# When will it fire next?
systemctl list-timers myjob.timer
# When did it last fire? Did it succeed?
systemctl status myjob.service
# Full log for the service
journalctl -u myjob.service
# Logs from the most recent invocation
journalctl -u myjob.service -n 50
# Manually trigger to test
sudo systemctl start myjob.service
# Verify the calendar expression
systemd-analyze calendar 'your expression here'
The "manually trigger" step is critical β it lets you test the service unit independently of the timer schedule. Cron has nothing equivalent (you have to wait for the next scheduled time or run the script manually as the right user).
The Common Gotchas
1. Forgetting to enable the timer
systemctl enable myjob.timer not myjob.service. The service is enabled implicitly via the Install section of the timer.
2. Confusing OnCalendar values
daily means *-*-* 00:00:00. If you want "once a day at 3am", you need to write *-*-* 03:00:00, not daily.
3. Not handling timezone correctly
By default, OnCalendar is in the system's local timezone. If your servers are on UTC but you want jobs to fire in a specific timezone, use the OnCalendar with timezone notation: OnCalendar=Mon *-*-* 09:00:00 Europe/Budapest.
4. Running as root unnecessarily
cron jobs in /etc/crontab run as root by default. When converting, ask whether the job actually needs root. Most don't. Set User= appropriately.
5. Forgetting WantedBy=timers.target
Without it, the timer is not enabled at boot. Standard for all timer units.
Operational Patterns Worth Adopting
Beyond the mechanical conversion, a few operational patterns make systemd timers genuinely better than cron in production:
Centralized timer inventory
systemctl list-timers --all shows every timer on the host with its next fire time and last fire time. Combined with config-management (Ansible, Puppet, Chef), you get a single source of truth for "what scheduled jobs run on this host." cron has nothing equivalent β you have to grep across crontab, /etc/crontab, /etc/cron.d/, /etc/cron.hourly/, and per-user crontabs.
Job dependencies via After= and Requires=
If your nightly report job depends on the database backup completing first, express it directly:
[Unit]
Description=Nightly report generation
After=db-backup.service
Requires=db-backup.service
The report service will not run unless the backup completed successfully. cron has no equivalent β you would write wrapper scripts that check for the backup's completion file.
Resource limits per job
[Service]
CPUQuota=50%
MemoryMax=2G
IOWeight=50
Cap a backup job at 50% CPU and 2 GB memory so it cannot starve user-facing services on the same host. cron-launched jobs have no resource limits unless you wrap them in cgroup tooling manually.
Job-specific working directories and environment
[Service]
WorkingDirectory=/var/spool/myjob
Environment="DEBUG=true"
EnvironmentFile=/etc/myjob/env
Cleaner than cron's "set everything in the script's first lines" pattern.
Centralized failure notifications
One notify-failure@.service template (as shown above) catches failures from every timer-launched job. You write the notification logic once. cron requires per-job error handling.
Frequently Asked Questions
What about anacron functionality?
Persistent=true in the timer is the systemd equivalent of anacron β missed runs catch up on next boot.
Can I keep using crontab on systemd hosts?
Yes β cron and systemd timers coexist fine. The migration can be gradual.
What about user crontabs?
User-level systemd timers exist (~/.config/systemd/user/foo.timer) and work cleanly. Need loginctl enable-linger $USER for them to run when the user is not logged in.
How do I send job output to email?
Don't. Use journalctl + a notification mechanism (Slack, PagerDuty). Email-based monitoring is a 1990s pattern that has not aged well.
What's the performance overhead?
Negligible. systemd timer firing is microseconds; the main cost is the actual job execution, which is the same as it would be under cron.
Can I keep using @reboot from cron?
Yes, but the systemd-native equivalent is OnBootSec= in a timer or simply listing the service in a startup target. Either works.
One Production Migration Story
A 200-server fleet we work with migrated all crontab entries to systemd timers in early 2026 over a six-week project. The motivation: a critical backup job had failed silently for three days because the cron output went to root@localhost (which the SMTP relay had been silently dropping for months). After conversion to systemd timers with proper OnFailure notifications, the next failure was caught within 5 minutes via PagerDuty. Bonus benefits surfaced during the migration: discovered four cron jobs that had not actually run for years (the binary they invoked had been removed); discovered three jobs that occasionally overlapped because the cron interval was shorter than the job runtime; discovered one job that ran as root unnecessarily and was duly downgraded to a dedicated service user. Total project cost: roughly 3 weeks of one engineer's time. Operational improvements: incident detection time on scheduled-job failures went from "days to weeks" to "5 minutes". Net assessment: would do again, would do sooner.
Further Reading from the Dargslan Library
- Linux Tutorials category β systemd, init systems, scheduling, and process management.
- Security & Hardening category β sandboxing, least-privilege patterns, and audit logging.
- Free cheat sheet library β printable references for systemctl, journalctl, and timer calendar syntax.
- Dargslan eBook library β comprehensive Linux administration courses.
The Bottom Line
systemd timers are strictly better than cron in 2026 for production systems. The migration is mechanical and worth doing β the logging, sandboxing, persistence, and randomization features all genuinely improve operations. Convert one job at a time, validate via journalctl, configure OnFailure notifications, and your scheduled-job reliability improves dramatically. cron will remain "good enough" for personal scripts and trivial cases; for anything you need to know failed, timers win.