A wall-clock guard so cron jobs don't drift across DST
I wanted a nightly job to fire at exactly 00:00 Central, every
night, forever. Debian's vixie-cron doesn't support CRON_TZ,
and the host runs in UTC. The naive approach was a 0 5 * * *
entry that would silently drift an hour twice a year.
What was happening
Two real cron lines for the same job, one for each DST half of the year, is the classic bad answer — you have to remember to edit them, the cutover edges look weird, and someone always forgets in spring or fall. I wanted one entry, no maintenance.
What I found
The trick is to fire cron more often than the actual run schedule, then short-circuit inside a wrapper script when the local wall clock isn't the value you want. CDT and CST both resolve through whatever the system timezone database says is right for "America/Chicago" at the moment the wrapper runs.
# /opt/scheduler/bin/wrapped-job.sh
#!/bin/bash
set -euo pipefail
JOB="$1"
HOUR=$(TZ=America/Chicago date +%H)
MIN=$(TZ=America/Chicago date +%M)
case "$JOB" in
nightly)
# nightly fires at 00:00 CT
[ "$HOUR" = "00" ] && [ "$MIN" = "00" ] || exit 0
;;
check)
# health check fires at 08:30 CT
[ "$HOUR" = "08" ] && [ "$MIN" = "30" ] || exit 0
;;
*)
echo "unknown job: $JOB" >&2
exit 1
;;
esac
# we're at the right Chicago wall-clock minute — actually do it
exec /opt/scheduler/bin/do-$JOB.sh
Cron fires the wrapper at the right minutes. The wrapper decides whether this particular fire is the one that should run the job.
The fix
Two cron entries, one per job slot:
0 * * * * /opt/scheduler/bin/wrapped-job.sh nightly
30 * * * * /opt/scheduler/bin/wrapped-job.sh check
The nightly entry fires twenty-four times a day, year-round. The wrapper only runs the actual job when it's 00:00 in Chicago. DST cutover handles itself: at 02:00 CST in November, the system clock rolls back, and there's only one 00:00 anyway. In March when 02:00 CST jumps to 03:00 CDT, 00:00 still happens once.
I also log every fire to a wrapper log file:
echo "$(date -Is) fire=$JOB hour=$HOUR min=$MIN" \
>> /home/chester/log/wrapper.log
That way I can confirm cron is actually hitting the wrapper even when the guard is short-circuiting all day. The number of "did the cron fire?" debug sessions I've avoided by having that log is embarrassing.
What I'd do differently
This pattern beats both the two-DST-entries approach and the
"convert local to UTC by hand and pretend it's stable" approach.
If I could move to systemd timers I would (they support
OnCalendar=*-*-* 00:00:00 America/Chicago natively), but the
wrapper is small enough that converting twenty cron jobs isn't
worth a migration just for this.
The other lesson: every cron job I write now logs its fires before doing the guard check, not after. If a job stops running, I want to know "did cron stop firing?" without having to break the production schedule to find out.