Skip to main content
systemd Timers vs Cron: What We Learned Switching

systemd Timers vs Cron: What We Learned Switching

KU
· Founder & Chief Editor
• 8 min read•Updated •97 views

We migrated most scheduled jobs from cron to systemd timers. The wins, the gotchas, and the cases we kept on cron anyway.

Key takeaways

  • Use systemd timers for scheduled jobs you need to trust: they log to journald, wait for mounts and services, catch up missed runs with Persistent=true, and jitter start times.
  • Keep cron for trivial one-liners, user crontabs and shared servers you do not own.
  • On Kubernetes, use a CronJob instead of either.

Use systemd timers for any scheduled job you need to trust, and keep cron for trivial one-liners. Timers log every run to journald, wait for mounts and services, catch up missed runs with Persistent=true, and page you via OnFailure=. Cron promises far less: no record of whether a job ran, and no dependency awareness.

Verified against

systemd 262 (latest man pages; v262.1 released 2026-10-16), against the freedesktop.org systemd.timer(5), systemd.time(7) and systemd.unit(5) man pages and the crontab(5) man page. Checked October 2026.

We migrated most of our scheduled jobs over the past year. This is what we kept, what we abandoned, and where cron is still the right answer.

cronsystemd timer
LoggingMails output to a local mailboxjournald, with exit codes
Missed runs while offSkipped (needs anacron)Run at next boot with Persistent=true
DependenciesNoneAfter=, RequiresMountsFor=
Failure alertingNone built inOnFailure= starts another unit
Start jitter across hostsNoneRandomizedDelaySec=
Setup costOne lineTwo unit files

The incident that started it

Our backup cron job failed silently for three weeks. The script exited non-zero because a mount wasn't ready, cron tried to email the output to a local mailbox nobody reads, and the job kept "running" on schedule while doing nothing. We found out when we needed a restore.

That one incident contained all three of our standing complaints about cron:

Logging is awkward. Output goes to local mail by default. We had piped stdout and stderr to log files by hand on every host, and failures still went unnoticed unless someone looked.

No dependency awareness. "Run this only after the database and the backup mount are up" isn't a thing in cron. Boot-time jobs racing their dependencies were a recurring source of "why did the 3:01 AM job fail last night?"

No retry or backoff. A failed job just fails. It runs again at the next scheduled time, possibly compounding the problem.

A timer is two units

A systemd timer is a pair: a .service that does the work and a .timer that says when. The split felt fussy at first; after a couple dozen of them it's habit, and it's what buys the observability.

/etc/systemd/system/db-backup.service
[Unit]
Description=Nightly database backup
Wants=network-online.target
After=network-online.target postgresql.service
RequiresMountsFor=/mnt/backups
OnFailure=notify-oncall@%n.service

[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/bin/db-backup.sh
TimeoutStartSec=2h
Nice=10
IOSchedulingClass=idle
/etc/systemd/system/db-backup.timer
[Unit]
Description=Run db-backup nightly

[Timer]
OnCalendar=*-*-* 02:30:00 UTC
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Enable it with systemctl enable --now db-backup.timer. Give both files the same prefix (db-backup.service and db-backup.timer); it saves confusion six months later.

RequiresMountsFor=/mnt/backups is the line our old cron job lacked: the service won't even attempt to run if the mount is missing, and After=postgresql.service holds it until the database is up. If a dependency isn't met, the job fails loudly instead of running against a half-ready system. OnFailure= starts another unit when this one fails, which is how a broken backup pages someone instead of emailing the void.

What we gained

Logs go to journald. Same place service logs go, tagged, with exit codes, cross-correlated in time order with everything else on the host. This is the single biggest day-to-day improvement.

Bash
systemctl list-timers
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.
systemd-analyze calendar 'Mon..Fri 02:00'   # prints the next elapse time

systemctl list-timers shows every timer with its last and next run on one screen, which beats parsing crontabs in your head.

Missed runs are caught up. Persistent=true means that if the machine was off at 2:30, the job runs at next boot instead of being skipped. Cron alone just misses it (anacron exists to patch that gap). For a laptop or a VM that isn't always on, this is decisive.

No thundering herd. RandomizedDelaySec=300 jitters the start by up to five minutes. Run the same cron line on 200 hosts and they all hit the backup target at exactly 02:30:00; timers spread the load. Cron has no equivalent.

Bounded retries. The service can use Restart=on-failure with StartLimitIntervalSec and StartLimitBurst, the same retry controls as any other systemd unit.

Readable schedules. OnCalendar=Mon..Fri 02:00 instead of 0 2 * * 1-5; *:0/15 for every 15 minutes. OnBootSec and OnUnitActiveSec add relative scheduling: "5 minutes after boot" or "30 minutes after the previous run".

The gotchas that bit us

Type=oneshot is mandatory. Without it, systemd treats the job like a long-running daemon. Backup scripts exit; oneshot tells systemd the service runs to completion.

OnCalendar is local time by default. We append UTC to every expression. Otherwise daylight-saving transitions can produce two runs or zero runs on the transition day.

Persistent=true runs once, not once per miss. A daily job that missed five days runs once when the host returns. Usually what you want; occasionally not.

No mail by default. Timers don't email output; you read the journal.

The calendar syntax is different enough to trip you. The format is weekday year-month-day hour:minute:second timezone. Verbose but unambiguous, and you'll run systemd-analyze calendar a few times before it sticks. Run it before every deploy.

Observability we bolted on

Beyond journalctl, three signals per host, all from infrastructure we already ran:

  • Failed unit count. systemctl --failed lists units in failed state; node_exporter scrapes it and we alert on any failed unit on a production host.
  • Last-run time per critical timer. A nightly backup that hasn't run in two days is a problem. A Prometheus exporter scrapes systemctl list-timers.
  • Timer drift. Persistent=true jobs running late or skipping usually means clock skew or a host that was off.

What we kept on cron, and where timers are wrong

Three categories stayed on cron:

User-level tasks. A few admin scripts run as a specific user. User-level timers exist, but explaining "you have to enable lingering for your account" cost more than a user crontab.

Trivial periodic things. "Once an hour, curl this endpoint to keep a session alive." A cron line takes five seconds to write; a unit pair takes thirty. If nobody cares when a run is missed, cron is fine.

Shared servers we don't own. On machines a partner team controls, their crontab works and we don't push our patterns onto them.

And four cases where timers are the wrong tool entirely: Kubernetes workloads (use CronJob resources, which run as Pods), multi-step workflows with conditional logic (Step Functions or Airflow), cross-host coordination like "run on whichever host has capacity" (timers are per-host), and event triggers rather than time triggers (systemd path units, inotify, or a queue).

How we migrated without a big bang

  1. New scheduled work goes in timers immediately. No new cron entries on hosts we control.
  2. Existing cron jobs migrate when touched. Editing a job's command? Convert it in the same change.
  3. Quarterly audit. A script lists every crontab across hosts; we pick a few to migrate.

After 12 months, about 70% of scheduled work runs on timers. The remaining 30% is mostly the user-level and shared-server cases above, and we probably won't push further.

Questions people ask

Do systemd timers run missed jobs like anacron?

Only with Persistent=true in the [Timer] section, and only for OnCalendar= timers. systemd stores the last trigger time on disk, and if the timer would have fired while the machine was off, the service runs immediately when the timer is next activated. It does not replay every missed run: a daily job that missed five days runs once. Without Persistent=true, a missed run is simply skipped, as with plain cron.

How do I check when a systemd timer will run next?

systemctl list-timers shows every active timer with its next and last trigger time. To test an expression before deploying it, run systemd-analyze calendar 'Mon..Fri 02:00', which normalizes the expression and prints the next elapse time. Add --iterations=5 to see several upcoming runs.

The decision, concretely

  • Would you want to know when the job breaks? Use a timer: backups, cert renewals, data syncs, and disk cleanup all qualify.
  • Does it depend on a mount, the network, or a database? Use a timer with After= and RequiresMountsFor=; cron can't express this.
  • One-line personal task nobody monitors? Leave it in cron.
  • Runs on Kubernetes or spans hosts? Neither; use a CronJob or a real scheduler.

To check what a cron line really means before you deploy it, the cron expression generator explains it in plain English and lists the next five run times.

The call we'd make

Make systemd timers the default for new scheduled work on hosts you control, with four settings as muscle memory: Persistent=true, a UTC suffix, Type=oneshot, and journal output, plus OnFailure= for anything that matters. That turns "the job silently stopped and we found out during a restore" into "the unit went failed and paged us at 2:31". Don't migrate stable cron jobs en masse; convert them when you touch them. Timers are a modest upgrade that fits naturally if you already run systemd, and the teams that gain most are the ones already working around cron's limits.

Sources

Explore topics:Linux
React
Share this post
Cite this article
Plain text

Kiril Urbonas (2026). systemd Timers vs Cron: What We Learned Switching. DevOpsNess. Updated 2026-10-31. https://www.devopsness.com/blog/systemd-timers-vs-cron-what-we-learned

Markdown

[systemd Timers vs Cron: What We Learned Switching](https://www.devopsness.com/blog/systemd-timers-vs-cron-what-we-learned) (Kiril Urbonas, DevOpsNess, updated 2026-10-31)

KU

Kiril Urbonas

Founder & Chief Editor

590 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.