A few of the small jobs on my home server used to live in one crontab: a script that syncs documentation into this blog’s site every couple of minutes, and a daily job that checks new domains against an ad and tracker classifier before anything reaches a blocklist. The server rebooted one night for a kernel update, somewhere around 3:40am. The 3am job that day just didn’t run. No error, no mail, nothing in any log I was looking at. I found out two days later, when the thing that job was supposed to keep fresh was two days stale.

That’s cron working exactly as designed. It fires at a wall clock time. If the machine isn’t up at that moment, the job doesn’t happen, and cron has no memory of what it missed. Not a bug. Just not what I actually needed from it.

What cron doesn’t do

A few gaps, in the order I ran into them:

  • A missed run stays missed. Nothing catches it up later.
  • Failures go to a local mail spool nobody reads, or get swallowed entirely if stdout is redirected to /dev/null, which is what most cron one-liners do.
  • There’s no way to declare “don’t start until Docker is up.” You just hope compose finished before the job needed it.
  • No jitter. Five jobs all set to 0 3 * * * fight over the same CPU and disk at the same second.
  • Checking whether a job ran, and what it printed, means remembering which logfile you redirected to, if you redirected to one at all.

None of that matters for a single throwaway script on a box that never reboots. It adds up once there are several jobs and the server reboots itself for updates on its own schedule.

The two-file model

A systemd timer pairs a timer unit with a service unit. For the docs sync job, that’s:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# docs-site-sync.service
[Unit]
Description=Sync documents into the Hugo docs site

[Service]
Type=oneshot
ExecStart=/opt/scripts/docs-site-sync.sh
User=tomakl
StandardOutput=journal
StandardError=journal
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# docs-site-sync.timer
[Unit]
Description=Run docs-site-sync every 2 minutes

[Timer]
OnBootSec=30
OnUnitActiveSec=2min
AccuracySec=10s

[Install]
WantedBy=timers.target

Two files is more ceremony than one crontab line, and the first time I set one of these up I grumbled about it. But the service file is reusable on its own. systemctl start docs-site-sync.service runs it right now, with full logging, without waiting for the schedule, which a crontab entry can’t do.

Relative schedules instead of wall clock

That timer doesn’t use a calendar expression at all. OnBootSec=30 and OnUnitActiveSec=2min mean “30 seconds after boot, then 2 minutes after the previous run finished.” That’s a different guarantee than cron’s */2 * * * *, which fires every two minutes on the clock whether or not the last run is still going.

For jobs that actually need a calendar, OnCalendar covers it:

1
2
3
4
[Timer]
OnCalendar=daily
RandomizedDelaySec=30min
Persistent=true

RandomizedDelaySec spreads the real start time across a 30-minute window instead of every daily job hitting midnight at once. You can check what an expression actually resolves to without waiting for it:

1
systemd-analyze calendar daily

It prints the next several trigger times. I wish I’d known that command existed before a decade of guessing at cron syntax by trial and error.

Persistent=true is the actual fix

This is the line that solved the original problem. Persistent=true tells systemd to remember the last time a timer fired, and if the box was off when it should have fired again, run it once as soon as the system comes back up. The classifier job used to vanish silently for a day whenever a reboot landed in its window. Now a missed midnight run just happens whenever the machine is next awake, which is the behavior I wanted from cron’s anacron the whole time and never got, mostly because I never set anacron up correctly either.

Dependencies instead of sleep-and-hope

A oneshot service that needs a container running can say so instead of guessing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[Unit]
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
ExecStartPre=/usr/bin/docker compose -f /opt/stack/docker-compose.yml up -d
ExecStartPre=/bin/sleep 15
ExecStart=/opt/scripts/the-actual-job.sh
RemainAfterExit=yes
Restart=on-failure
RestartSec=30s

There’s still a sleep 15 in there, because “the container is running” and “the container is ready to take connections” are different facts, and I haven’t bothered writing a real readiness check for this particular script. Systemd buys ordering, not omniscience. Restart=on-failure with a RestartSec is the part cron genuinely can’t do at all, short of wrapping the whole job in a hand-rolled retry loop.

What got better in practice

systemctl list-timers lists every timer with its last run and next scheduled run in one table. journalctl -u docs-site-sync.service shows exactly what a job printed and whether it exited non-zero, with real timestamps, searchable, and kept across reboots if the journal is persistent. I used to grep a logfile and hope the script had written to it in roughly the format I remembered. Now I don’t have to hope.

Where cron still wins

I haven’t purged cron from the box. For a single disposable “run this at 2am, I don’t care if it occasionally gets skipped” entry, a crontab line is one line and a timer is two files. That overhead is real, and I’d reach for cron again for something genuinely one-off. For anything that has to run reliably, catch up after downtime, wait on a real dependency, or retry on failure, the two files pay for themselves the first time a job you cared about silently didn’t run and you didn’t notice for two days.

If you’re curious what’s actually behind those two example jobs, the ad and tracker domain classifier is the daily one, and the homelab backup audit covers the cron job that used to sit right next to it before I moved everything over.