systemd timers instead of cron
I kept everything in crontab -e for years. It works right up until a job fails
silently at 03:00 and you find out a week later that backups have been empty the
whole time. cron runs your command, mails the output to a mailbox nobody reads,
and forgets about it. systemd timers give you logs, status, accurate scheduling,
and missed-run catch-up for free, so on every machine I own now the answer is a
timer.
A timer is two files
cron packs the schedule and the command into one cryptic line. systemd splits
them: a .service unit says what to run, a .timer unit says when. The two
share a name.
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
Type=oneshot tells systemd the service is a task that runs and exits, not a
daemon it should keep alive. Enable the timer (not the service) and check
when it fires next:
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer
list-timers prints the next run, the last run, and how long until each — the
single most useful command in the whole system. You never have to mentally parse
a cron expression to answer "when does this actually run?" again.
OnCalendar, and how to test it
The OnCalendar= syntax is more readable than cron once it clicks. A few that
cover most needs:
OnCalendar=hourly # top of every hour
OnCalendar=*-*-* 03:00:00 # every day at 03:00
OnCalendar=Mon *-*-* 09:00:00 # Mondays at 09:00
OnCalendar=*-*-01 00:00:00 # first of every month
Don't guess whether an expression means what you think. systemd-analyze will
expand it for you and print the next few firing times:
systemd-analyze calendar 'Mon *-*-* 09:00:00' --iterations 3
If the syntax is wrong it tells you immediately, instead of silently never
running — which is the classic cron failure where a stray % eats the rest of
the line.
The two flags that actually sold me
Persistent=true. If the machine was asleep or powered off when the timer
should have fired, systemd runs the job once, immediately, on the next boot. cron
just skips missed runs entirely. For a laptop or any box that isn't on 24/7 this
is the difference between "backup ran late" and "backup never ran".
Logs that exist. Every run is in the journal, tagged by unit:
journalctl -u backup.service --since today
journalctl -u backup.service -n 50 --no-pager
You get stdout, stderr, the exit code, and timestamps — the things cron throws into a mail spool. When a job misbehaves you read the log instead of guessing.
Don't fire everything at the same second
If a dozen timers all say 03:00:00, they stampede the disk at once. Spread them
out with a randomized delay:
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=900
Persistent=true
Each run now starts somewhere in a 15-minute window. On a server with several
maintenance jobs this alone smooths out the nightly I/O spike. AccuracySec=1us
forces exact timing if you ever genuinely need it, but the default loose accuracy
is what you want for batch work.
Verifying it works without waiting until 03:00
You don't have to wait for the schedule to test the job. Trigger the service unit by hand:
sudo systemctl start backup.service
systemctl status backup.service
status shows whether the last run succeeded, the exit code, and the tail of the
log. Once that's green, trust the timer to fire on schedule.
When cron is still fine
If you have one trivial job on an always-on server and you'll never look at its output, cron is less typing and that's a legitimate trade. The moment you care whether a job ran — backups, certificate renewals, anything whose silent failure costs you — move it to a timer. Strict scheduling you can query beats a line in a file you have to remember to check.
The mental model that stuck: a timer doesn't make the job correct, it makes the
job observable. list-timers answers "did it run, will it run", and the
journal answers "what happened". That's everything cron never gave me.
Common problems
My timer is enabled but never fires
You almost certainly enabled the .service instead of the .timer. Enable the
timer — systemctl enable --now backup.timer — and confirm it's armed with
systemctl list-timers backup.timer. If the timer isn't listed at all, the
[Install] WantedBy=timers.target section is missing from the .timer file.
I edited the unit file and nothing changed
systemd caches unit files. After any edit run sudo systemctl daemon-reload,
then restart the timer. Skipping the reload is the single most common "why is it
still using the old schedule" cause.
The job runs but I can't see its output
Logs live under the service unit, not the timer:
journalctl -u backup.service. The timer unit only logs scheduling; stdout and
stderr from your script are attached to the service it triggered.