The Core Problem
Cron uses wall-clock time, not UTC. If your server is set to a local timezone and DST occurs, a cron job scheduled for 0 2 * * * (2:00 AM daily) might:
- Run twice when clocks fall back (2:00 AM occurs twice)
- Never run when clocks spring forward (2:00 AM is skipped)
- Drift by an hour throughout summer vs winter if you expected UTC behavior
The Safe Solution: Set Servers to UTC
The simplest fix: set your server's system timezone to UTC. Then cron always uses UTC, and DST is irrelevant. This is the standard practice for production servers:
# Check current timezone
timedatectl # Linux (systemd)
date # Any Unix
# Set to UTC
sudo timedatectl set-timezone UTC # systemd Linux
sudo ln -sf /usr/share/zoneinfo/UTC /etc/localtime # legacy
Cron with Explicit Timezone (GNU cron / Vixie cron)
Some cron implementations support the CRON_TZ or TZ environment variable to set the timezone per crontab:
CRON_TZ=America/New_York
# Run at 9 AM New York time every weekday
0 9 * * 1-5 /usr/local/bin/send-morning-report.sh
CRON_TZ=Asia/Seoul
# Run at midnight Seoul time
0 0 * * * /usr/local/bin/daily-backup.sh
Warning: Not all cron implementations support this. Test on your specific OS.
Modern Schedulers: Better Timezone Support
Modern job schedulers provide explicit timezone support:
# Celery (Python)
from celery.schedules import crontab
app.conf.beat_schedule = {
"morning-report": {
"task": "tasks.morning_report",
"schedule": crontab(hour=9, minute=0),
# Always use UTC in Celery — convert at task level
}
}
# GitHub Actions — always UTC
on:
schedule:
- cron: "0 9 * * 1-5" # 9 AM UTC = 6 PM KST
# AWS EventBridge (CloudWatch Events) — UTC only
# "cron(0 9 ? * MON-FRI *)" # 9 AM UTC
Django-Q / django-tasks Scheduling
# In Django, schedule tasks in UTC
from django.utils import timezone
from datetime import timedelta
# Schedule for a specific local time
import zoneinfo
seoul = zoneinfo.ZoneInfo("Asia/Seoul")
target_local = datetime(2024, 3, 1, 9, 0, tzinfo=seoul)
target_utc = target_local.astimezone(timezone.utc)
Task.objects.create(run_at=target_utc)
DST-Safe "Business Hours" Scheduling
If you need to send a report at "9 AM business time" for users in New York, do not hardcode UTC-5. Store the business rule as a timezone name and compute the UTC equivalent fresh each day:
from zoneinfo import ZoneInfo
from datetime import datetime, time, timezone
def next_9am_utc(iana_tz: str) -> datetime:
tz = ZoneInfo(iana_tz)
today = datetime.now(tz).date()
local_9am = datetime.combine(today, time(9, 0), tzinfo=tz)
return local_9am.astimezone(timezone.utc)