Skip to content

systemd timers vs cron: when we switch and when we don’t

We run about 40 scheduled jobs across the servers we look after. Backups, log rotation, certificate renewals, a few sync scripts, the monitoring check that posts to Slack. Six of those are systemd timers. The rest are cron entries and most of them will stay that way.

That ratio surprises people, because nearly every article about systemd timers vs cron on the first page of Google is a version of “stop using cron.” The technical arguments in those posts are right. They are also written from a laptop or a personal VPS, where nobody else ever touches the box. On a server somebody has to inherit from you, the math changes.

Three schedulers, not two

Cron is a daemon that reads a table. One line holds the schedule and the command together. It has been around since the 1970s, it exists on every Unix-like system, and any admin who walks up to your server can read it without being told how.

A systemd timer splits that line into two files. A .service unit says what to run, a .timer unit says when. Both live in /etc/systemd/system/, both get enabled like any other unit, and output goes to the journal without you configuring anything.

Then there is the layer people forget about. On a WordPress box, WP-Cron sits above both. It fires on page loads, so on a quiet site scheduled posts and plugin updates just do not happen until someone visits. The fix is to switch it off in wp-config.php and drive wp cron event run --due-now from the system scheduler, which means you have now picked cron or a timer anyway. Worth knowing before you spend ten minutes arguing about the wrong layer.

The two things that actually break

Feature comparisons are not much help here. Ask instead what goes wrong.

The first one is a job that failed and nobody heard it. Cron mails output to the local user. On a VPS with no MTA configured, that mail lands in a spool nobody reads, or nowhere at all. We picked up a client server last year where the nightly database dump had been failing for four months. Disk was fine, the cron line looked correct, and the error had been going to /var/mail/root the whole time. A timer would have put that same error in journalctl -u backup.service, where our log shipper reads it.

The second is a job that ran twice. Cron does not care whether the previous run finished. A backup that usually takes twenty minutes hits a slow night, takes ninety, and an hourly schedule stacks four mysqldump processes on top of each other. We filled a 40 GB disk that way on a store that had outgrown its own schedule. Systemd will not start a service that is already running, so the second trigger does nothing and says so in the log.

If neither of those has bitten you, cron is fine. Most of our jobs are one-line, idempotent, and loud when they fail. Rewriting them into unit files would buy us nothing.

The panel problem nobody writes about

This is the reason most of our jobs are still crontab lines, and I could not find it mentioned anywhere on page one.

CyberPanel, cPanel, DirectAdmin and Plesk write the crontab themselves. Backup schedules, Let’s Encrypt renewals, quota runs, log processing: the panel owns those entries and regenerates them on upgrades and config changes. Convert one to a timer and it disappears from the panel UI. The next person opens the backup page, sees no schedule, assumes backups are off, and creates a second one. Now there are two, running at different hours, writing to the same target.

We do not fight the panel. Across our eight CyberPanel sites, anything the panel schedules stays in the panel. Timers are only for jobs we added ourselves, outside the panel’s view.

Two other cases where cron stays. Portability, if a script ships to a client’s Alpine container or an older box we do not control. And discoverability: crontab -l is one command, whereas finding a timer means knowing to run systemctl list-timers and then chasing the .service to see what it calls. Two extra hops at 3am for someone who did not build it.

Where the second file pays for itself

Overlap protection is the big one, and it costs nothing to set up because it is the default. Backups, imports, syncs, anything long.

Persistent=true runs a missed job on the next boot. Cron skips it silently. If your VPS reboots for kernel updates at the hour your weekly report generates, you lose that week and nothing tells you.

systemctl list-timers prints every scheduled unit, when it last ran and when it fires next, in one table. Cron has no equivalent. You read the crontab and do the arithmetic in your head.

After=network-online.target is a real dependency. The cron version is sleep 30 at the top of the script and hope.

And you get resource limits. MemoryMax= and CPUQuota= on the service unit will cap a runaway import before it takes the web server down with it. We put a 512 MB ceiling on a client’s product feed importer after it OOM-killed PHP-FPM twice in a week.

The syntax

A service:

# /etc/systemd/system/db-backup.service
[Unit]
Description=Nightly database dump
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/db-backup.sh
MemoryMax=512M

And a timer:

# /etc/systemd/system/db-backup.timer
[Unit]
Description=Run db-backup nightly

[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Then systemctl enable --now db-backup.timer.

OnCalendar is where people come unstuck. It is not cron syntax. Check yours before trusting it:

systemd-analyze calendar '*-*-* 03:15:00'
systemd-analyze calendar 'Mon *-*-* 09:00:00'
systemd-analyze calendar '*:0/5'

That last one is every five minutes. The command prints the next few fire times, so you can confirm you meant what you typed. Do this every time. Calendar expressions are the one place where a string that looks obviously correct quietly means something else. The ArchWiki page on timers has the fullest set of examples if you need more.

RandomizedDelaySec is worth knowing too. On a host running several VMs, a fleet of jobs all set to 03:15 hammers the disk at once. Five minutes of jitter spreads them out.

What it costs to convert

Roughly twenty minutes a job, most of it testing. Ten minutes to write the two units, five to check the calendar expression, five to run it once by hand with systemctl start db-backup.service and confirm the journal shows what you expected.

Then delete the crontab line. Delete it, do not comment it out, or in a year you will have a commented cron entry and a live timer doing the same work and no way to tell which one is authoritative. We have found that exact situation on inherited servers more than once and it is a nuisance to unpick.

Thirty jobs is therefore a full day of work to convert everything. We have never thought that was a day well spent. We move the jobs that hit one of the two failure modes and leave the rest alone.

What we run

Backups and long imports go on timers, always, for the overlap protection. Certificate renewals stay wherever the panel or certbot put them. WP-Cron replacements stay on cron, one line per site, because a shared box might have twelve of them and twelve pairs of unit files is worse than twelve crontab lines. Monitoring checks stay on cron at five-minute intervals, which is exactly the setup in our cron and Slack monitoring script; that job is stateless, so overlapping runs do no harm.

Picking for a new server today: use cron until a job needs overlap protection, catch-up after reboot, or a real dependency on something else being up. Then move that job. Not all of them. “Modernize the whole crontab” is a weekend you will not get back.

Whichever you pick, make failures loud. A job that fails quietly is worse than no job, because you think you have a backup. Send output where a human will see it: the journal, Slack, an email address that exists. That matters more than which scheduler pulled the trigger.

We set this up on retainer as part of server automation work, usually alongside the hardening checklist on a new box. If you have inherited a server and cannot say what is scheduled on it or whether any of it still works, that audit takes a couple of hours and is worth doing before anything else. More than one of the server post-mortems we run has traced back to a scheduled job nobody knew was there.

Start here

Hit this one
yourself?

If any of the above is happening on your stack, send us the symptoms. We triage the same day and quote before we start.

Which layer is on fire?
Your details stay with us. Always.