systemd timers instead of cron

· linux systemd

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 timersystemctl 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.