systemd timers vs cron: why I finally switched
Cron is fine until a job overlaps, silently fails or misses a run during a reboot. Here's how systemd timers handle those cases and when I still reach for cron.
On this page
For years my rule was simple: if it runs on a schedule, it goes in cron. One line, everyone can read it, done. Then a nightly job started taking longer than its interval, two copies ran at once, and both wrote to the same file. Nobody noticed for a week because cron mailed the errors to a local mailbox that nobody reads.
That was the push I needed to move scheduled jobs on our servers to systemd timers. Here’s what the switch looks like and what I actually gained.
The minimal setup#
A timer is two units: a service that does the work and a timer that decides when to start it.
The service#
[Unit]
Description=Nightly report export
[Service]
Type=oneshot
User=reports
ExecStart=/usr/local/bin/export-report --since yesterdayType=oneshot tells systemd the unit is “active” while the command runs and done when it exits.
The timer#
[Unit]
Description=Run report export every night
[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.targetThen enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now report.timer
systemctl list-timers report.timerlist-timers shows the last run, the next run and how long until it fires. That single command already beats grep CRON /var/log/syslog.
Two lines in that timer are worth explaining:
Persistent=trueremembers when the timer last fired. If the machine was off at 03:15, the job runs right after boot instead of waiting a day.RandomizedDelaySec=10mspreads the start over ten minutes, which matters when fifty machines would otherwise hit the same database at exactly 03:15.
What I get for free#
No overlapping runs#
If the timer fires while report.service is still running, systemd doesn’t start a second copy, because starting an already active unit does nothing. With cron I used to wrap every job in flock -n /run/lock/report.lock. I still think flock is a great tool, but I no longer need it for this.
Logs in one place#
Everything the job prints to stdout and stderr goes to the journal with timestamps and the exit status:
journalctl -u report.service --since today
systemctl status report.serviceTo watch a run live, journalctl -fu report.service follows the log; stop it with Ctrl+C.
Failure hooks#
Add this to the [Unit] section of the service:
OnFailure=notify-failure@%n.serviceA template unit notify-failure@.service can then post to chat or open a ticket, with the failed unit’s name in %i. That replaced the mailbox nobody read.
Resource limits#
Since it’s a regular service, you can set MemoryMax=, CPUQuota=, Nice= or IOSchedulingClass=idle on a heavy job so it can’t starve the main application.
A real environment#
Cron runs with a nearly empty PATH and no idea about your shell profile. A service has a defined environment you can inspect with systemctl show report.service -p Environment, and you can set it explicitly with Environment= or EnvironmentFile=.
Checking calendar expressions#
The OnCalendar syntax is more readable than cron’s five fields, but it’s still easy to get wrong. systemd ships a checker:
$ systemd-analyze calendar "Mon..Fri 09:00" --iterations=3
Original form: Mon..Fri 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Mon 2026-07-13 09:00:00 CEST
(in UTC): Mon 2026-07-13 07:00:00 UTC
From now: 22h left
Iter. #2: Tue 2026-07-14 09:00:00 CEST
Iter. #3: Wed 2026-07-15 09:00:00 CEST
For “every N minutes” schedules, monotonic timers are often a better fit than calendar ones:
[Timer]
OnBootSec=5min
OnUnitActiveSec=15minThis runs five minutes after boot and then fifteen minutes after each previous start, so the schedule drifts with the work instead of being tied to the wall clock.
One-off jobs#
For “run this once in half an hour”, systemd-run creates a transient timer without writing any files:
sudo systemd-run --on-active=30m --unit=reindex /usr/local/bin/reindex-searchIt shows up in list-timers and logs to the journal like any other unit, which at never did.
Side by side#
| cron | systemd timer | |
|---|---|---|
| Definition | one line in a crontab | a service and a timer unit |
| Overlapping runs | your problem (flock) | prevented by the unit state |
| Missed runs while off | skipped | caught up with Persistent=true |
| Logs | mail or whatever the job does | the journal, with exit status |
| Failure alerts | MAILTO | OnFailure= |
| Resource limits | nice, by hand | MemoryMax=, CPUQuota=, … |
When I still use cron#
I haven’t deleted crontab from my brain. It’s still the better choice when:
- the system has no systemd (some containers, minimal images, BSD boxes);
- a user without root needs a quick personal job and
systemctl --userwith lingering feels like overkill; - the job is genuinely trivial and the team already knows where to look for it.
But for anything that matters in production (backups, exports, cleanups that touch shared data) timers have earned their two extra files. The overlap protection and the journal alone paid for the migration within a month.