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. NoMAILTO, no>> /var/log/thing.log 2>&1boilerplate on every line. - No overlap. A
oneshotservice 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=trueruns 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.targetmeans "don't run until the network's actually up". cron has no concept of that. - Randomized spread.
RandomizedDelaySecjitters 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=simplefor a script. UseType=oneshotfor a job that runs and exits. With the defaultType=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'sPATHtweaks, 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
OnCalendarwith a zone or run withPersistent. Checktimedatectlif a job fires an hour off. - Secrets. Don't inline passwords in
ExecStart. UseEnvironmentFile=with a0600root-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/.