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.targetsudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
sudo systemctl list-timers backup.timerThree 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;anacronis the bolt-on. WithPersistent=trueyou 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.serviceshows you stdout, stderr, exit code, and timing. No more>> /var/log/backup.log 2>&1in the crontab line.journalctl -u backup.service --since '7 days ago'is a perfectly readable history. - Easy “run it now.”
systemctl start backup.serviceruns 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.servicefile. Cron jobs don’t have any of this without asystemd-run --scopewrapper. - Dependencies. A timer can depend on another service:
After=postgresql.serviceensures the DB is up before the dump runs. With cron, you write a sleep loop or apg_isreadycheck 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 weeklylogrotate, a 5-minutehealthcheck.sh. Two unit files for those is overkill —crontab -ewins. - User-specific jobs. Per-user crontabs (
crontab -eas your user) are simpler than the systemd--userequivalent, 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>&1And 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.targetTo 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.serviceWhere 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.
