You wrote 0 9 * * *, expecting a 9:00 job. It ran at 14:45, or 3:00, or an hour off after the clocks changed. The expression was fine. The problem is which clock the scheduler reads.
Step 1: Find the scheduler's time zone
| Where it runs | Time zone used |
|---|---|
| Linux crontab | The system time zone (timedatectl or /etc/localtime) |
| Docker container | Usually UTC unless you set TZ |
GitHub Actions schedule | UTC |
| Vercel Cron Jobs | UTC |
| Kubernetes CronJob | The controller's zone (often UTC), or spec.timeZone if set |
| AWS EventBridge (classic rules) | UTC |
On a Linux server:
timedatectl # shows "Time zone: ..." date # local time date -u # UTC
If those two date outputs differ, cron is reading the first one.
Step 2: Convert your local time to UTC
Work out your offset from UTC and subtract it.
- Nepal (UTC+5:45): 9:00 local = 03:15 UTC →
15 3 * * * - India (UTC+5:30): 9:00 local = 03:30 UTC →
30 3 * * * - London in winter (UTC+0): 9:00 local = 09:00 UTC →
0 9 * * * - New York in winter (UTC−5): 9:00 local = 14:00 UTC →
0 14 * * *
Watch the date line. 2:00 in Kathmandu is 20:15 the previous day in UTC. That matters if you also set a day of week: Monday 2:00 in Nepal is 15 20 * * 0, which is Sunday in UTC.
You can check the result in plain English with the Cron Expression Explainer.
Step 3: Set the time zone explicitly where you can
cronie (Fedora, RHEL, many others) supports CRON_TZ at the top of the crontab:
CRON_TZ=Asia/Kathmandu 0 9 * * * /usr/local/bin/report.sh
Debian's default cron does not reliably honour it, so test on your system before relying on it.
Kubernetes:
spec: schedule: "0 9 * * *" timeZone: "Asia/Kathmandu"
Docker: set TZ and install tzdata in the image if you want local time inside the container.
ENV TZ=Asia/Kathmandu
Hosted schedulers that only accept UTC: convert, and leave a comment with the local time so the next person doesn't "fix" it:
# 09:00 Asia/Kathmandu - cron: "15 3 * * *"
Step 4: Decide what to do about daylight saving
If your local zone uses daylight saving time (Europe, most of North America), a local-time schedule has two bad nights a year:
- Clocks go forward: the 2:00–3:00 hour doesn't exist. A job at 2:30 may be skipped.
- Clocks go back: the 1:00–2:00 hour happens twice. A job at 1:30 may run twice.
Your choices:
- Schedule in UTC and accept the job moves by an hour in local terms twice a year. Best for backups and data jobs.
- Schedule in local time but avoid the 1:00–3:00 window. Best for things people see, like a 9:00 report email.
- Make the job idempotent so a double run is harmless and a skipped run is caught next time.
Nepal and India don't observe daylight saving, so a UTC conversion there stays correct all year.
Other reasons it "ran at the wrong time"
- It didn't run at all, and the next run looked late. Check logs:
grep CRON /var/log/syslogorjournalctl -u cron. - Hosted schedulers are best-effort. GitHub Actions scheduled workflows can start several minutes late at busy times.
- The server clock is wrong. Make sure NTP is on:
timedatectl set-ntp true.