You set a job for 0 9 * * *. It runs at 10:00. Or at 4:00. Or it ran fine all summer and has been an hour late since the clocks changed. Almost every "cron runs at the wrong time" problem comes down to one fact: cron doesn't know what time zone you meant. It uses whatever the system it runs on is set to.
Here's how to find out which time zone your cron uses, how to change it per job, and how to avoid the daylight saving time traps.
Step 1: Find out what time zone cron is using
On most Linux servers, cron uses the system time zone. Check it:
timedatectl
Local time: Wed 2026-10-28 15:00:12 UTC
Universal time: Wed 2026-10-28 15:00:12 UTC
Time zone: Etc/UTC (UTC, +0000)
Cloud images default to UTC almost everywhere. If you developed on a laptop set to your local time, that's the source of the offset.
Note that cron reads the time zone when it starts. If you change the system time zone, restart the cron service so it picks up the change:
sudo timedatectl set-timezone Europe/Berlin
sudo systemctl restart cron # "crond" on RHEL, Fedora and similar
Changing the whole server's time zone affects logs and every other job too. Usually it's better to leave the server on UTC and handle time zones per job.
Step 2: Set a time zone for a single crontab
Some cron implementations support a CRON_TZ variable at the top of a crontab:
CRON_TZ=Europe/Berlin
0 9 * * 1-5 /usr/local/bin/morning-report.sh
This works in cronie, the cron used by RHEL, Fedora, CentOS and Arch. Support in other implementations varies, so check man 5 crontab on your system and search for CRON_TZ. If it's not mentioned, it's probably ignored without any warning.
Don't confuse it with TZ=... in the crontab. TZ sets the time zone for the command (so date inside your script prints local time), but it doesn't change when cron fires the job.
Step 3: The portable trick: run hourly and check the local hour
If you can't rely on CRON_TZ, schedule the job every hour and let it decide whether this is the right hour in the time zone you care about:
0 * * * * [ "$(TZ=America/New_York date +\%H)" = 09 ] && /usr/local/bin/morning-report.sh
This runs at 9:00 New York time all year, through both daylight saving changes, on any cron implementation. The cost is 23 no-op wake-ups a day, which is nothing. Remember to escape % as \% inside a crontab.
Step 4: Understand what DST does to your schedule
Daylight saving time creates one hour that doesn't exist (spring forward) and one hour that happens twice (fall back). Jobs scheduled inside those hours behave differently depending on the cron implementation:
- A job at 2:30 am on the spring-forward night may be skipped, or run at 3:00.
- A job at 1:30 am on the fall-back night may run twice.
Classic Vixie-derived cron has special handling for small clock changes to reduce this, but don't depend on details that differ across versions. The simple rules:
- Avoid scheduling local-time jobs between 1:00 and 3:00 am.
- If a job must run exactly once a day, make it idempotent: check whether today's run already happened before doing the work.
- If the time doesn't matter to humans (backups, cleanup), schedule in UTC, which has no DST.
Time zones in other schedulers
systemd timers accept a time zone directly in OnCalendar:
[Timer]
OnCalendar=Mon..Fri *-*-* 09:00:00 Europe/Berlin
Persistent=true
Check how systemd reads it with:
systemd-analyze calendar "Mon..Fri *-*-* 09:00:00 Europe/Berlin"
It prints the next elapse time in both your local time and UTC.
Kubernetes CronJobs have a timeZone field (stable since Kubernetes 1.27):
apiVersion: batch/v1
kind: CronJob
metadata:
name: morning-report
spec:
schedule: "0 9 * * 1-5"
timeZone: "Europe/Berlin"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: report
image: example/report:latest
Without timeZone, the schedule follows the time zone of the kube-controller-manager, which is usually UTC.
GitHub Actions, Vercel Cron Jobs and Cloudflare Workers Cron Triggers evaluate schedules in UTC. Convert your local time to UTC, and remember the offset changes with DST. A job that must run at 9:00 local time year-round needs either two schedules you switch twice a year, or the "run hourly and check" approach inside the job.
Step 5: Log the time zone with every run
When debugging, print both local and UTC time at the start of the job. It ends arguments about what time it "really" ran:
#!/usr/bin/env bash
echo "start local=$(date '+%F %T %Z') utc=$(date -u '+%F %T')"
# ... the actual work ...
0 9 * * * /usr/local/bin/job.sh >> /var/log/job.log 2>&1
Converting a local time to UTC quickly
GNU date can do the conversion for you:
date -u -d 'TZ="Europe/Berlin" 09:00 next Monday' '+%H:%M UTC'
Run it once for a date in summer and once in winter to see both offsets.
A cleaner option for HTTP jobs
If the job is a call to one of your endpoints, it doesn't need to live on a server with a system clock you have to think about. delay.run runs standard five-field cron schedules against your URL, retries one-off jobs that fail, and logs each run's request, response and latency, which makes it easy to see exactly when a run happened.
Summary
- Cron uses the system time zone, which on servers is usually UTC.
-
CRON_TZworks in some implementations, not all. Checkman 5 crontab. -
TZ=changes the command's clock, not the schedule. - The hourly-check trick works everywhere and handles DST.
- systemd timers and Kubernetes CronJobs let you set a time zone explicitly.
- Keep local-time jobs out of the 1-3 am window, and make daily jobs safe to run twice.
Top comments (0)