Back to blog
FILE 0x19·A WALL-CLOCK GUARD SO CRON JOBS DON'T DRIFT ACROSS DST

A wall-clock guard so cron jobs don't drift across DST

May 16, 2026 · cron, linux, scheduling

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.