systemd timers as a cron replacement: when OnCalendar beats a crontab line (and when it doesn’t)

You have a backup script. You add it to /etc/crontab. It runs on schedule for a year. Then one day it stops, because the box was suspended for ten minutes during the run window and cron’s “minute X of hour Y” model doesn’t believe in catching up. You discover the failure two weeks later, when you actually need a backup.

This is the kind of failure systemd timers solve elegantly, and the kind of edge case crontab cannot. But systemd timers are not always the right answer. Here is when each one wins for the kind of small-scale recurring jobs that fill a personal server’s cron.daily.

The smallest possible systemd timer

A timer is two unit files: a .service that says what to run, and a .timer that says when. Drop them in /etc/systemd/system/, enable, done.

# /etc/systemd/system/backup.service
[Unit]
Description=Run nightly backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
User=root
# /etc/systemd/system/backup.timer
[Unit]
Description=Trigger nightly backup

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=15min

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
sudo systemctl list-timers backup.timer

Three things in that [Timer] section deserve attention because they’re the reasons systemd timers exist:

  • OnCalendar — a more readable schedule grammar than crontab. OnCalendar=Mon..Fri 09:00, OnCalendar=*-*-01 04:00, OnCalendar=hourly, OnCalendar=*-*-* 02..04:00:00 (any time between 02:00 and 04:00).
  • Persistent=true — if the system was off / suspended at the scheduled time, run the job at the next boot. Cron simply doesn’t do this; anacron is the bolt-on. With Persistent=true you don’t need anacron at all.
  • RandomizedDelaySec=15min — adds a random offset within that window, so 100 servers running the same timer don’t all hit your backup target at exactly 03:00:00.

Where systemd timers clearly win

  • Catch-up after sleep / downtime. Laptops, intermittent VPSes, anything that wasn’t running at 03:00. Persistent=true + a marker file under /var/lib/systemd/timers/ remembers the last successful run.
  • Logs go straight to the journal. journalctl -u backup.service shows you stdout, stderr, exit code, and timing. No more >> /var/log/backup.log 2>&1 in the crontab line. journalctl -u backup.service --since '7 days ago' is a perfectly readable history.
  • Easy “run it now.” systemctl start backup.service runs the same job manually with the same env, same user, same logging. Cron requires you to copy the line and run it by hand.
  • Resource limits. MemoryMax=1G, CPUQuota=20%, IOWeight=10, Nice=10 — all in the .service file. Cron jobs don’t have any of this without a systemd-run --scope wrapper.
  • Dependencies. A timer can depend on another service: After=postgresql.service ensures the DB is up before the dump runs. With cron, you write a sleep loop or a pg_isready check at the top of every script.

Where crontab is still the better answer

  • One-line jobs you’ll forget about. A nightly certbot renew, a weekly logrotate, a 5-minute healthcheck.sh. Two unit files for those is overkill — crontab -e wins.
  • User-specific jobs. Per-user crontabs (crontab -e as your user) are simpler than the systemd --user equivalent, especially on remote boxes where the user-level systemd lingering setup adds complexity.
  • Cross-distro / cross-OS scripts. macOS, Alpine in some configurations, BSDs, busybox-based containers — they all have cron and don’t all have systemd. If your script needs to run on a Pi, a Mac, and an Ubuntu VPS, cron is the lowest common denominator.
  • Containers. If you’re running Alpine + crond inside a Docker container, that’s the right tool. Putting systemd inside a container is a much bigger hammer.

One real-world conversion

Here’s a crontab line:

15 3 * * * root /usr/local/bin/restic-backup.sh >> /var/log/restic.log 2>&1

And the equivalent timer set, with the things crontab couldn’t do — catch-up after downtime, a startup delay, a memory cap, journal logging:

# /etc/systemd/system/restic.service
[Unit]
Description=Restic backup
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=root
MemoryMax=2G
Nice=15
ExecStart=/usr/local/bin/restic-backup.sh

# /etc/systemd/system/restic.timer
[Unit]
Description=Restic backup schedule

[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=30min

[Install]
WantedBy=timers.target

To inspect the result: systemctl list-timers shows next-run times, last-run times, the unit name, and the time left until next fire — a much more useful view than crontab -l‘s opaque schedule lines.

One gotcha worth flagging

Timers don’t email you on failure the way cron does (cron mails the job’s stderr to the user’s mbox by default). For systemd, you need to wire failure notifications yourself — usually with an OnFailure= dependency that triggers an email or a webhook unit:

# in restic.service
[Unit]
OnFailure=notify-failure@%n.service

Where notify-failure@.service is a templated unit that takes the failed unit name as %i and curls a Healthchecks.io / ntfy.sh / slack webhook. It’s an extra ten lines once, and then every timer-driven job on the box gets the same notification path for free.

Photo: Linux directory listing on a terminal by Pixabay on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.