I moved almost everything off cron and onto systemd timers, and the reason is boring: when a timer job fails, I can actually see why. journalctl -u myjob gives me the exit code and the full output. A cron job that fails emails root on a box with no mail configured and vanishes into the void.

TL;DR: a timer is two files — a .service that does the work and a .timer that says when:

# /etc/systemd/system/backup.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/backup.timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
systemctl enable --now backup.timer

Details below.

Why bother, honestly

cron works. I'm not religious about this. But timers buy you real things:

  • Logging that exists. Output goes to the journal: journalctl -u backup.service. No MAILTO, no >> /var/log/thing.log 2>&1 boilerplate on every line.
  • No overlap. A oneshot service won't start again while the previous run is still going. cron will happily stack five copies of a slow job until the box falls over.
  • Catch-up. Persistent=true runs a missed job when the machine boots. A laptop or a VM that was off at 03:00 still gets its backup. cron just skips it.
  • Dependencies. After=network-online.target means "don't run until the network's actually up". cron has no concept of that.
  • Randomized spread. RandomizedDelaySec jitters start times so a fleet of servers doesn't all hammer the same backup target at exactly 03:00.

When is cron still fine? A personal box, a one-line job, something you'll never debug. Don't rewrite working cron entries for the sake of it. New jobs I care about go to timers.

The .service half

The service is a normal unit with Type=oneshot — it runs, finishes, and that's a successful "completed", not a crash:

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

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# optional: run as a specific user, load secrets from a root-only file
User=backup
EnvironmentFile=/etc/backup.env

You can run this on demand any time to test it, without waiting for the timer:

systemctl start backup.service
journalctl -u backup.service -n 50 --no-pager

That alone is a reason to switch — testing a cron line means editing the crontab to a minute from now and waiting. New to units in general? See /systemd-service-unit-file/.

The .timer half

Same base name, .timer extension. systemd pairs backup.timer with backup.service automatically:

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

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

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

Note you enable the timer, not the service. The service has no [Install] section because it's triggered, not enabled directly.

OnCalendar syntax, decoded

This is the part that trips everyone up coming from cron's five fields. systemd uses DOW YYYY-MM-DD HH:MM:SS:

OnCalendar=*-*-* 03:00:00          # every day at 03:00
OnCalendar=Mon *-*-* 09:00:00      # every Monday 09:00
OnCalendar=*-*-01 00:00:00         # first of every month, midnight
OnCalendar=*-*-* *:00:00           # top of every hour
OnCalendar=*-*-* *:0/15:00         # every 15 minutes
OnCalendar=Mon..Fri *-*-* 18:00:00 # weekdays at 18:00
OnCalendar=hourly                  # shorthand; also daily, weekly, monthly

Don't guess — validate it:

systemd-analyze calendar "Mon..Fri *-*-* 18:00:00"
# prints the next few times it will actually fire

That command has saved me from "why didn't it run" more than once. There's also OnBootSec= and OnUnitActiveSec= for "5 minutes after boot" and "every 6 hours since last run" style schedules, which cron simply can't express.

See what's scheduled

One command shows every timer, when it last ran and when it fires next — the dashboard cron never had:

systemctl list-timers --all
NEXT                        LEFT     LAST                        PASSED  UNIT            ACTIVATES
2026-06-23 03:00:00 UTC     8h left  2026-06-22 03:00:12 UTC     15h ago backup.timer    backup.service

Migrating a cron job

Take a crontab line:

0 3 * * * /usr/local/bin/backup.sh

That becomes the .service + .timer pair above, with OnCalendar=*-*-* 03:00:00. The script doesn't change. Once the timer's verified with list-timers and a manual systemctl start, delete the crontab line so it doesn't run twice.

Gotchas

  • Enabling the wrong unit. Enable the .timer, not the .service. Enable the service and it tries to run at boot and never again.
  • Type=simple for a script. Use Type=oneshot for a job that runs and exits. With the default Type=simple, systemd considers it "started" the instant it launches, which breaks overlap protection and ordering.
  • Relative paths and missing env. Timers run with a minimal environment — no cd, none of your shell's PATH tweaks, no profile. Use absolute paths in the script, set what you need in the unit. This is the classic "works in my shell, fails as a timer" trap.
  • OnCalendar timezone. It follows the system timezone unless you set OnCalendar with a zone or run with Persistent. Check timedatectl if a job fires an hour off.
  • Secrets. Don't inline passwords in ExecStart. Use EnvironmentFile= with a 0600 root-owned file.

For real-world timers in action, the nightly backup-and-restart setup in /hytale-server-backups-auto-restart-systemd/ is all timers, and rotating logs the systemd way pairs nicely with /logrotate-linux-log-rotation/.


Related posts