Every Linux box you have ever touched has a crontab on it, and every crontab has the same dirty secret: jobs fail silently. A backup script dies at 3 AM, cron dutifully mails the output to root's local mailbox — which nobody reads — and you discover the gap three weeks later when you need the restore. Cron is not broken. It is just a scheduler from an era before observability was a word.

systemd timers are the boring, grown-up replacement. They do not add features for the sake of it; they fix the three things that actually hurt in production: silent failures, thundering herds, and missing logs.

The unit pair: a timer and a service

A systemd timer never runs a command directly. It triggers a service unit with the same name, and the service unit holds the actual command. Two small files:

# /etc/systemd/system/db-backup.service
[Unit]
Description=Nightly database backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/db-backup.sh
# /etc/systemd/system/db-backup.timer
[Unit]
Description=Run database backup nightly

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=15min

[Install]
WantedBy=timers.target

Enable and start the timer — not the service:

systemctl enable --now db-backup.timer
systemctl list-timers

list-timers alone is worth the migration: one command shows every scheduled job, when it last ran, when it runs next, and whether it is active. Try getting that from a fleet of crontabs.

Translating your crontab

The OnCalendar= syntax covers everything cron expressions do, in plainer language:

CronOnCalendar
0 3 * * *daily or *-*-* 03:00:00
*/15 * * * **:0/15
0 9 * * 1Mon *-*-* 09:00:00
@reboota service with WantedBy=multi-user.target, or a timer with OnBootSec=5min

The three ops wins

1. Failures become visible. Every run's stdout and stderr land in the journal automatically. journalctl -u db-backup.service shows the full history. Add OnFailure=status-email@%n.service to the service unit and failures page you through your normal alerting path — no local-mailbox archaeology.

2. No more thundering herds. Twenty servers all firing backups at 0 3 * * * is how you melt a shared NFS target. RandomizedDelaySec=15min spreads the load across a window with one line. AccuracySec= controls how precisely the timer fires, which also saves wakeups on laptops and battery-powered devices.

3. Missed runs catch up. A laptop asleep at 3 AM simply skips a cron job. Persistent=true makes the timer run the missed job once the machine is back, which is exactly what you want for backup and maintenance tasks on machines that sleep.

Two more that matter in practice: timers respect dependencies (After=network-online.target means your sync job waits for the network instead of racing it), and they integrate with cgroups, so a runaway job's CPU and memory usage is visible per-service instead of vanishing into the process soup.

The migration playbook

Do not rewrite everything on day one. Migrate in this order:

  1. Start with the job that hurts most — the backup or sync job whose silent failure would page you. Convert it, watch one full cycle with journalctl -u <name>.service, then add OnFailure= alerting.
  2. Move jobs that need ordering next. Anything that must run after the network, after a mount, or after another job is a timer-shaped problem.
  3. Keep simple user crontabs alone for now. A developer's personal */5 * * * * reminder script gains nothing from the migration. Convert system and infrastructure jobs first.

One gotcha: timer units need the [Install] section with WantedBy=timers.target, or enable will complain. And remember to start the timer, not the service — starting the service just runs the job once.

When cron still wins

Honesty requires the other side. Cron is universal: it works identically on every Unix, in minimal containers, and in environments without systemd. If you manage a mixed fleet with BSD boxes or Alpine containers, cron is the portable answer. And for a single line in a user's crontab, cron's simplicity is a feature.

But if your machines run systemd — and in 2026, most of yours do — your scheduled jobs deserve the same observability as everything else on the box. Cron schedules commands. systemd timers schedule operations. Migrate the ones that matter.

Sources