Last month my Hetzner box took a kernel update and rebooted itself at 03:04. The nightly database dump was scheduled for 03:10. The machine came back up around 03:22, cron looked at the clock, saw it was no longer 03:10, and did precisely nothing. I found out two days later, when I went looking for a dump that didn’t exist.
Nothing failed. That’s the part that bothered me. There was no error, no alert, no angry log line. The job just quietly didn’t happen, and cron has no memory that it was ever supposed to.
I’d been putting off learning systemd timers for years because crontab is one line and a timer is two files. That reboot changed the maths for me. Here’s what I moved, how the units actually look, and the jobs I’ve left on cron because moving them would be pointless.
Cron is a clock, not a scheduler
That distinction sounds pedantic until it bites you. Cron wakes up every minute, checks whether any line matches the current time, and runs what matches. If the machine is off, suspended, or still booting during that minute, the run is gone. There’s no queue and no catch-up.
Debian ships anacron to soften this, and anacron does work, but only for daily, weekly and monthly jobs on a machine that isn’t always on. It doesn’t help you with “every 15 minutes” or “twice a day at fixed local times”.
The other thing cron does badly is tell you anything. Output goes to local mail. On a minimal VPS there’s usually no MTA installed, so stdout and stderr from your job go into the void. A non-zero exit code produces no reaction at all. And the environment is deliberately tiny: PATH is roughly /usr/bin:/bin, your shell profile is never sourced, and half the “works in my terminal, breaks in cron” bugs I’ve debugged over the years came down to that one difference.
I’ve written before about how much of my server pain came from shipping bloated Docker images and not looking closely at what I’d actually deployed. This was the same shape of problem: I assumed something was running because I’d configured it once and never checked.
The two files that replaced my crontab line
Here’s the job I’m using as the example. In cron it was one line:
*/15 * * * * cd /srv/app && /usr/bin/php artisan queue:prune >> /var/log/prune.log 2>&1
In systemd it’s a service that describes what to run, and a timer that describes when. The service:
# /etc/systemd/system/queue-prune.service
[Unit]
Description=Prune finished queue jobs
[Service]
Type=oneshot
User=www-data
WorkingDirectory=/srv/app
ExecStart=/usr/bin/php artisan queue:prune
And the timer, which has to share the service’s name for systemd to pair them automatically:
# /etc/systemd/system/queue-prune.timer
[Unit]
Description=Run the queue prune every 15 minutes
[Timer]
OnCalendar=*:0/15
Persistent=true
RandomizedDelaySec=30
[Install]
WantedBy=timers.target
Then:
sudo systemctl daemon-reload
sudo systemctl enable --now queue-prune.timer
Two files instead of one line. I’m not going to pretend that’s an improvement in ergonomics. What you buy for the extra file is everything below.
Note the Type=oneshot. A oneshot service is expected to run and exit, and systemd tracks it as “activating” until it does, rather than deciding it crashed. If you leave it on the default Type=simple you’ll get confusing state in systemctl status.
Persistent=true is the whole reason I bothered
That single line is the fix for my 03:10 problem. With Persistent=true, systemd writes a timestamp file under /var/lib/systemd/timers/ every time the timer fires. On boot it compares that stamp against the schedule, and if a run was missed while the machine was down, it runs it immediately instead of waiting for the next window.
It only works in combination with OnCalendar, which is worth knowing before you go sprinkling it on monotonic timers and wondering why nothing changed.
RandomizedDelaySec=30 is the companion setting. Persistent timers all want to fire the instant the machine is back, so if you have several of them, or several machines, they stampede at once. A small random offset spreads them out. On a single box with three timers it barely matters. On a fleet it’s the difference between a boot and a thundering herd.
OnCalendar can be checked before you deploy it
Cron syntax is five fields and a prayer. I have absolutely deployed 0 */2 * * 1-5 believing it meant something other than what it meant.
systemd gives you a calculator:
$ systemd-analyze calendar "*-*-* 09,17:00" --iterations=3
Original form: *-*-* 09,17:00
Normalized form: *-*-* 09,17:00:00
Next elapse: Sat 2026-08-15 17:00:00 +04
(in UTC): Sat 2026-08-15 13:00:00 UTC
From now: 3h 12min left
Three iterations, real timestamps, in your server’s timezone. I now run this before every timer I write and it has caught two mistakes that would otherwise have shipped.
Timezones deserve a warning. By default a calendar spec is evaluated in the system timezone, so timedatectl set-timezone Asia/Dubai on the server is doing real work in that schedule above. Recent systemd versions also accept a timezone suffix directly in the spec, like Mon..Fri 09:00 Asia/Dubai. Whether yours does is a one-second question:
systemd-analyze calendar "Mon..Fri 09:00 Asia/Dubai"
If it errors, your systemd is too old for that syntax and you’re back to setting the box timezone or doing the UTC arithmetic yourself. Better to find out on your laptop than at 09:00 on a Monday.
To see what’s actually armed:
$ systemctl list-timers --all
NEXT LEFT LAST PASSED UNIT
Sat 2026-08-15 14:15:00 +04 12min Sat 2026-08-15 14:00:12 +04 2min queue-prune.timer
Sun 2026-08-16 03:10:00 +04 13h Sat 2026-08-15 03:10:04 +04 10h db-dump.timer
LAST and PASSED are the columns I check first. If LAST on a job is older than its interval, something is wrong and I know before anyone else does.
Logs and failure handling, which cron never gave me
Every run goes to the journal, tagged with the unit:
journalctl -u queue-prune.service --since "2 days ago"
journalctl -u queue-prune.service -f
No redirect, no logrotate config, no log file I forgot to rotate filling a disk at 2am. That alone would have been worth the migration.
Failures can trigger something. OnFailure= points at another unit to start when this one exits non-zero:
[Unit]
Description=Nightly database dump
OnFailure=alert@%n.service
# /etc/systemd/system/[email protected]
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-failure %i
%n expands to the failing unit’s name and %i picks it up on the other side, so one template unit covers every job on the box. Mine posts to a webhook. It took about ten minutes to write and it’s the reason I now find out about failures in seconds rather than days.
You also get the resource controls that come with any other systemd service, which cron has no concept of:
[Service]
TimeoutStartSec=600
MemoryMax=512M
CPUQuota=50%
And overlap protection is free. If a run is still going when the timer next fires, systemd won’t start a second copy of the same unit. Cron will cheerfully start a fifth copy of a job that’s been hung for an hour, which is how you turn a slow query into an outage. I’ve done this. The load average was memorable.
What this looks like on a box I actually run
The blog you’re reading this on is published by a timer. There’s an ingest job that pulls feeds every few hours, and a publish job that fires twice a day at 09:00 and 17:00 local time. The publish job is the one I care about: if it silently skips, a post that was supposed to go out doesn’t, and I have no idea until someone tells me.
That job is a oneshot service with Persistent=true, RandomizedDelaySec=120, and an OnFailure= hook that pings me. In the seven months it’s been running I’ve had three failures. All three of them I knew about within a minute, because the failure unit fired. Under cron I’d have found out when I noticed the blog had gone quiet.
The other thing that changed is where the config lives. The unit files sit in the git repo next to the code they run, and a deploy script copies them into /etc/systemd/system/ and reloads. systemctl cat queue-prune.timer prints the effective config including any drop-in overrides, so there’s never a question of what’s actually on the box versus what I think is on the box. My crontab, by contrast, lived only on the machine, was edited with crontab -e at 1am on at least two occasions, and existed in no version control anywhere. That’s not cron’s fault, but the format encourages it.
If you want the honest accounting: the migration for four jobs took me an afternoon, most of which was reading man pages rather than typing. Nothing about it was hard. I just hadn’t done it.
Where cron is still the right answer
I haven’t migrated everything, and I don’t plan to.
Cron is one line, and for a job whose failure costs nothing, one line is correct. My “clear this cache directory” job is still a crontab entry and will stay one.
Cron is also portable in a way systemd isn’t. Alpine, BusyBox, most containers, macOS, shared hosting with a cPanel box you don’t control: no systemd anywhere. If your job needs to run in a container, systemd timers aren’t an option and you should be looking at the orchestrator’s scheduler instead.
If you don’t have root, systemd user timers work but need loginctl enable-linger $USER, otherwise your units get killed when your session ends. That’s an easy thing to miss and a confusing thing to debug.
And there’s a real cost to the split-file model: your scheduling logic now lives in two files per job instead of one line in one place. systemctl list-timers gives you the overview back, but crontab -l was simpler and I miss it. A lot of the infrastructure work I do for clients is exactly this kind of trade, and I’ve written more about how I approach it in my work.
What to do this week
Don’t migrate your crontab. Migrate one job.
Pick the single scheduled task where a silent skip would actually cost you something. Backup, invoice run, whatever it is for you. Write the service and timer, run systemd-analyze calendar on the schedule before you enable it, then check systemctl list-timers to confirm it’s armed.
Then do the part most people skip: reboot the machine on purpose during the window the job should have run, and check journalctl -u yourjob.service afterwards to confirm the catch-up actually fired. If it didn’t, you’ve learned something cheap. If it did, you never have to think about that job again.
The reference material is short and worth the twenty minutes. The syntax for calendar expressions lives in systemd.time(7), the unit options are in systemd.timer(5), and systemd-analyze(1) documents the calendar subcommand I keep recommending.