You need to run something on a schedule — a nightly backup, an hourly cleanup, a weekly report. You reach for cron, because that's what everyone reaches for. It works. But on any machine running systemd (which is almost all of them now), there's a better default, and it costs you about the same two small files.
Here's the case against cron, and a complete walkthrough of the thing I use instead.
What cron quietly gets wrong
Cron is a brilliant, durable idea. But the classic implementation has four rough edges you've probably hit:
-
The schedule is write-only. Can you read
01,31 04,05 1-15 1,6 *at a glance? Neither can I. You write it once and pray. - Output goes into a black hole. Whatever your job prints to stdout or stderr usually vanishes — or worse, gets mailed to the local root mailbox you forgot exists.
- There's no run history. Did last night's job run? Did it fail? Cron won't tell you. You find out when something downstream breaks.
-
The environment is a surprise. Cron runs with a stripped-down
$PATHand almost no environment, so a script that works in your shell mysteriously fails under cron.
A systemd timer fixes all four — and the logging and history come for free, because the job runs as a normal systemd unit.
A timer is two small files
A systemd timer is really two units that share a name: a service that says what to do, and a timer that says when. Say you have a backup script at /usr/local/bin/backup.sh.
First, the service — /etc/systemd/system/backup.service:
[Unit]
Description=Nightly database backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
Type=oneshot means "run it, wait for it to finish, then it's done" — exactly right for a script that does a job and exits.
Then the timer — /etc/systemd/system/backup.timer. It must share the service's stem (backup), because a timer triggers the matching .service by default:
[Unit]
Description=Run the nightly backup at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
Now turn it on. You enable and start the timer, not the service:
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now backup.timer
That's it. enable makes it survive reboots; --now starts it immediately. The service itself stays idle until the timer fires it (you can still run it by hand any time with sudo systemctl start backup.service).
The schedule: OnCalendar, and how to read it
OnCalendar= is the wall-clock schedule. Its format is more readable than cron once you see the shape:
*-*-* 02:30:00
│ │ │ │ │ ╰── second
│ │ │ │ ╰───── minute
│ │ │ ╰──────── hour
│ │ ╰─────────── day
│ ╰───────────── month
╰─────────────── year
An asterisk means "every." So *-*-* 02:30:00 is "every year, every month, every day, at 02:30:00" — i.e. 2:30 every morning. There are also shorthands: daily is just *-*-* 00:00:00, weekly is Monday at midnight, and so on.
Don't guess — validate. systemd-analyze parses any expression and tells you exactly when it will fire:
$ systemd-analyze calendar '*-*-* 02:30:00'
Normalized form: *-*-* 02:30:00
Next elapse: Tue 2026-06-09 02:30:00 UTC
From now: 7h left
It even accepts cron-style wildcards, so you can paste an old expression in and have it explained back to you.
From a crontab line to a timer
If you're migrating, the mapping is mechanical. A crontab line is five fields — minute, hour, day-of-month, month, day-of-week — and most translate straight onto OnCalendar:
| crontab | meaning | OnCalendar= |
|---|---|---|
30 2 * * * |
02:30 every day | *-*-* 02:30:00 |
0 * * * * |
top of every hour |
*-*-* *:00:00 (or hourly) |
*/15 * * * * |
every 15 minutes | *:0/15 |
0 4 * * 1 |
04:00 every Monday | Mon *-*-* 04:00:00 |
When in doubt, paste the right-hand side into systemd-analyze calendar and confirm the next-fire times line up before you trust it.
Run relative to an event, not just the clock
Here's something plain cron can't do cleanly: run a job relative to something happening, not at a fixed wall-clock time.
A cleanup job is the classic example. If it's hard-coded to 03:00 and your machine boots at 09:00, the 03:00 slot was missed and there was nothing to clean anyway. What you usually mean is "an hour after boot, then every hour after that":
[Timer]
OnBootSec=1h
OnUnitActiveSec=1h
OnBootSec=1h fires one hour after boot; OnUnitActiveSec=1h fires one hour after the service last ran, which makes it repeat. No fixed clock time involved — the schedule follows the machine's actual life.
Why a timer can fire a little late
Set a timer for 02:30:00, then notice it actually ran at 02:30:24? Nothing is broken. By default systemd gives every timer a one-minute accuracy window (AccuracySec=1min) and may fire anywhere inside it. That's deliberate — it lets the kernel batch nearby wakeups together instead of waking the CPU over and over, which saves power and, across a fleet, smooths out load.
For a backup or a cleanup, a few seconds of slop is irrelevant; leave it alone. When you genuinely need to-the-second firing, tighten the window:
[Timer]
OnCalendar=*-*-* 02:30:00
AccuracySec=1us
Now it fires as close to 02:30:00 as the machine can manage. Just know that very tight windows across many timers give up the power-saving coalescing, so reach for it only when the precision actually matters.
Where cron's problems went
This is the part that sells it.
Output and history are just there. Because the job runs as a systemd unit, everything it prints is captured in the journal, with timestamps and exit status:
$ journalctl -u backup.service
Jun 09 02:30:01 host systemd[1]: Starting Nightly database backup...
Jun 09 02:30:14 host backup.sh[4021]: dumped 412 MB to /backups/db-20260609.sql.gz
Jun 09 02:30:14 host systemd[1]: backup.service: Deactivated successfully.
"Did last night's backup run, and did it work?" is now one command, not a guess. Add -u backup.service --since yesterday and you've got history.
The clean environment is a feature — with one gotcha. A timer's ExecStart= does not run in a shell, and it starts from a nearly empty $PATH. That trips people up, so know it up front:
- There's no shell, so pipes and redirects don't work in
ExecStart=.ExecStart=/usr/bin/echo hi | grep hwill not do what you think. If you need shell features, call one explicitly:ExecStart=/usr/bin/bash -c '...'. - Use absolute paths (
/usr/local/bin/backup.sh, notbackup.sh), or invoke/usr/bin/envso your tools resolve.
It feels stricter than cron, but it's the same strictness that makes a timer predictable instead of "works on my machine."
See everything at a glance
One of my favorite commands: systemctl list-timers shows every timer, when it last ran, and when it fires next.
$ systemctl list-timers
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-06-09 02:30:00 UTC 7h left Mon 2026-06-08 02:30:01 UTC 16h ago backup.timer backup.service
Tue 2026-06-09 00:00:00 UTC 5h left Mon 2026-06-08 00:00:02 UTC 18h ago logrotate.timer logrotate.service
Tue 2026-06-09 06:12:00 UTC 11h left Mon 2026-06-08 06:12:00 UTC 12h ago fstrim.timer fstrim.service
The whole machine's schedule, in one place, in human time. There's no cron equivalent.
You don't always need root
Everything above lived in /etc/systemd/system/ with sudo — that's system-wide. But you can run timers as your own user, no root at all. Put the same two files in ~/.config/systemd/user/ and add --user:
$ systemctl --user enable --now backup.timer
$ journalctl --user -u backup.service
One catch worth knowing: by default a user's timers only run while that user is logged in. To keep them running on a server after you log out, enable lingering once — sudo loginctl enable-linger $USER — and from then on your timers run whether you're logged in or not.
Three options worth knowing
| Capability | cron | systemd timer |
|---|---|---|
| Readable schedule | cryptic fields | OnCalendar=*-*-* 02:30:00 |
| Job output & exit status | lost / mailed to root | journalctl -u <unit> |
| Run history | none | in the journal |
| Catch up a missed run | no | Persistent=true |
| Spread out load (anti-stampede) | no | RandomizedDelaySec= |
| Wake from suspend to run | no | WakeSystem=true |
| Schedule relative to boot/last-run | no |
OnBootSec= / OnUnitActiveSec=
|
Three of those are worth a closer look:
-
Persistent=truestores the last run on disk. If the machine was off when the job was due, the timer runs it as soon as the machine comes back, instead of silently skipping until the next slot. (This only applies toOnCalendar=timers — it's the line that makes a laptop backup actually happen.) -
RandomizedDelaySec=1hdelays each firing by a random amount up to the value you give. If a fleet of machines would otherwise all hit an API or a package mirror at exactly 02:30, this smears them across the hour and kills the stampede. -
WakeSystem=truelets an elapsing timer wake a suspended machine to run the job (you re-suspend it yourself afterward if you want).
A second example: weekly Docker cleanup
The backup was one shape; here's another you'll actually use. Docker quietly accumulates dangling images, stopped containers, and unused networks until they fill a disk — the exact "where did my space go" problem from every sysadmin's week. A weekly prune on a timer keeps it in check.
/etc/systemd/system/docker-prune.service:
[Unit]
Description=Prune unused Docker data
[Service]
Type=oneshot
ExecStart=/usr/bin/docker system prune -f
/etc/systemd/system/docker-prune.timer:
[Unit]
Description=Weekly Docker prune
[Timer]
OnCalendar=Sun 03:00
Persistent=true
RandomizedDelaySec=30min
[Install]
WantedBy=timers.target
enable --now the timer and forget about it. prune -f clears dangling images, stopped containers, and unused networks; add -a if you also want to drop images no container currently uses (you'll re-pull them as needed). Two details are doing real work: the absolute /usr/bin/docker path, because there's no $PATH to lean on, and RandomizedDelaySec=30min, so a rack of hosts don't all prune at 03:00 sharp.
When a timer doesn't fire
The day a timer silently does nothing, walk this short list — it's almost always one of these:
-
You forgot
daemon-reload. After editing any unit file, runsudo systemctl daemon-reloadso systemd re-reads it. Skipping this is the number-one cause of "but I changed it." -
You enabled the service, not the timer.
enable --nowbelongs onbackup.timer, notbackup.service. Check withsystemctl list-timers --all— if your timer isn't in the list, it isn't active. -
The names don't match. A timer triggers the service with the same stem, so
backup.timerlooks forbackup.service; a typo means it fires nothing. (SetUnit=explicitly if you want different names.) -
No
[Install]section. WithoutWantedBy=timers.target,enablehas nothing to hook into and the timer won't come back after a reboot.
To see what actually happened, check both units:
$ systemctl status backup.timer # active? when does it fire next?
$ journalctl -u backup.service # what did the last run print — and did it fail?
And to be told the moment a job fails, instead of finding out downstream, point the service at a handler with OnFailure=:
[Unit]
OnFailure=notify-failure@%n.service
systemd starts notify-failure@backup.service whenever the job exits non-zero — wire that unit to an email, a Slack hook, or whatever you already watch.
What to take away
- A systemd timer is two small files: a
.service(what to run) and a.timer(when), sharing a name. Enable and start the.timer. -
OnCalendar=is a readable wall-clock schedule;systemd-analyze calendar '<expr>'checks it before you commit.OnBootSec=/OnUnitActiveSec=schedule relative to events instead. - Output, exit status, and history land in the journal automatically —
journalctl -u <service>. That alone fixes cron's worst habits. - The one gotcha:
ExecStart=is not a shell and starts with a bare$PATH. Use absolute paths; wrap shell features inbash -c. -
systemctl list-timersshows the whole machine's schedule at once.Persistent=,RandomizedDelaySec=, andWakeSystem=cover the cases cron simply can't.
Cron isn't broken, and nobody's coming to take it away. But the next time you're about to edit a crontab, write two short unit files instead. The first time you run journalctl -u and actually see why last night's job failed, you'll get it.
Top comments (0)