A cron expression contains no time zone. It is five numbers, and the clock they are measured against is decided entirely by the machine — or the container, or the managed service — the scheduler runs on. That gap between "02:30" and "02:30 where?" accounts for a large share of cron incidents, and it gets worse twice a year when daylight saving moves the clocks underneath a running schedule.
Which clock does cron actually read?
The cron daemon evaluates schedules against the system local time of the host it runs on. On a Linux server that is whatever /etc/localtime points at, which you can check with:
timedatectl # systemd hosts
date +'%Z %z' # prints e.g. UTC +0000
cat /etc/timezone # Debian-family
The answer is very often UTC, because cloud images default to it, while the developer writing the schedule is thinking in their own local time. A job written as "run at 09:00 so the report is ready when the team starts" then fires at 09:00 UTC — 04:00 in New York, 18:00 in Sydney.
The run times shown by the builder and explainer on this site are rendered in your browser's local time, which is a useful sanity check on the shape of a schedule but is not the clock your server will use. Confirm the server's zone before trusting a wall-clock time.
Setting a server to a local zone so one report lands at the right hour also moves every other cron job on that machine, including ones installed by packages. On a shared host, prefer per-job time zones or UTC schedules with offset arithmetic over changing the system clock.
Setting the time zone for a job
There are three approaches, in descending order of reliability.
1. CRON_TZ in the crontab
Vixie-derived crons (cronie on RHEL/Fedora, the cron package on Debian/Ubuntu) support a CRON_TZ assignment. It applies to every schedule line below it, until the next assignment:
CRON_TZ=America/New_York
0 9 * * 1-5 /usr/local/bin/morning-report.sh
CRON_TZ=UTC
0 3 * * * /usr/local/bin/nightly-backup.sh
This is the cleanest option where it is available. It is not universal: busybox cron ignores it, and some minimal container crons do too. Test it rather than assuming.
2. TZ inside the command
Portable, and explicit at the point of use — but note that it affects only how the command interprets time, not when cron decides to start it:
0 9 * * 1-5 TZ=America/New_York /usr/local/bin/report.sh
Here cron still fires at 09:00 in the host's zone. Use this when the script needs to format dates in a particular zone, not to move the schedule.
3. Compute the UTC offset yourself
If neither is available, convert the intended local time to UTC and schedule that — accepting that the job will drift by an hour when the target zone changes its offset. For a schedule that must track local time year-round, this means two crontab lines and a seasonal edit, which is exactly the maintenance burden CRON_TZ exists to avoid.
Spring forward: the hour that never happens
When a zone moves from standard to summer time, local clocks typically jump from 02:00 to 03:00. Wall-clock times in that missing hour do not occur at all on that date. A job scheduled at 30 2 * * * has no 02:30 to match.
Implementations diverge in what they do about it:
- Vixie cron and cronie apply a special rule: jobs scheduled at a fixed wall-clock time inside the skipped interval are run once, immediately after the jump. Your 02:30 job runs at 03:00.
- Jobs with a wildcard in the hour or minute field (
*/10 * * * *) are not rescued — they simply do not fire during the hour that does not exist, so you get one hour fewer runs that day. - Quartz fires misfired triggers according to the trigger's misfire instruction, which by default runs the missed execution once as soon as possible.
- Schedulers that evaluate in UTC (Kubernetes CronJobs by default, AWS EventBridge) are unaffected — there is no skipped hour in UTC. The local wall-clock time of the run shifts by an hour instead.
Fall back: the hour that happens twice
When clocks go back, local times between 01:00 and 02:00 occur twice. Naively, a job at 30 1 * * * would run twice.
- Vixie cron and cronie suppress the duplicate: a fixed-time job in the repeated interval runs only once.
- Wildcard schedules run twice.
*/15 * * * *gets eight runs in that doubled hour rather than four, because each matching wall-clock minute genuinely occurs twice. - Some minimal crons make no adjustment at all and will run a fixed-time job twice. If double execution would be harmful, the schedule is not the right place to fix it.
Even outside DST, cron offers no exactly-once guarantee: a daemon restart, a paused VM, a clock correction or a retried Kubernetes job can all produce a duplicate run. Make the job idempotent — key writes on a business date, use an advisory lock, check whether the work is already done — and DST stops being a special case.
What each implementation does
| Scheduler | Default zone | Per-job zone | DST handling |
|---|---|---|---|
| Vixie cron / cronie | System local | CRON_TZ | Fixed-time jobs in skipped or repeated hours run exactly once. Wildcard schedules are not adjusted. |
| busybox cron | System local | Not supported | No special handling; duplicates and skips are possible. |
| systemd timers | System local | OnCalendar with a zone suffix | Handles transitions explicitly; Persistent=true also catches up missed runs after downtime. |
| Kubernetes CronJob | UTC (controller's zone) | spec.timeZone (v1.27+) | UTC schedules are immune. With timeZone set, the same skip/repeat questions apply. |
| AWS EventBridge | UTC | Scheduler supports a zone; Rules do not | UTC only for Rules, so no DST effect. |
| Quartz / Spring | JVM default | zone attribute on @Scheduled | Misfire instructions decide what happens to skipped executions. |
| GitHub Actions | UTC | Not supported | UTC only. Schedules also run late under load. |
The platform guide goes into the other ways these schedulers differ — field counts, dialects and delivery guarantees.
The case for scheduling in UTC
UTC has no daylight saving and never will, so a UTC schedule fires at a fixed interval forever. For anything whose timing is about the machine rather than about people — backups, compaction, cache warming, log shipping, data pipeline steps — UTC is simply the correct choice, and the "wrong" local hour it lands on in summer is irrelevant.
Local time earns its complexity only when a human is on the other end: a report that must be in an inbox before the working day starts, a reminder at a sensible hour, a batch that has to align with a business day or a market close. For those, set CRON_TZ (or the platform equivalent) to a named IANA zone such as Europe/Amsterdam — never to a fixed offset like UTC+1, which by definition cannot follow the transitions.
Machine-facing schedules: UTC. Human-facing schedules: a named IANA zone. Either way, avoid putting anything in the 01:00–03:00 local window, which is where every transition edge case lives.
Containers and managed schedulers
Containers have their own trap: the image's time zone is usually UTC regardless of the host's, because most base images ship no /etc/localtime and no tzdata at all. A cron job that worked on the VM starts firing at a different wall-clock hour once containerised, and setting TZ=Europe/Amsterdam has no effect if the zone database is missing — the process silently falls back to UTC.
To run a container on local time you need both:
# Debian/Ubuntu base
RUN apt-get update && apt-get install -y tzdata
ENV TZ=Europe/Amsterdam
# Alpine base
RUN apk add --no-cache tzdata
ENV TZ=Europe/Amsterdam
For Kubernetes, prefer spec.timeZone on the CronJob over baking a zone into the image — it is declarative, visible in the manifest, and does not depend on what the container image happens to contain.
On managed schedulers, read the documentation on when the job is guaranteed to run as carefully as on what zone it uses. Most of them promise "at or after" the scheduled time, not "at" it, and several deprioritise scheduled work at peak times. If a job must land within a tight window, cron of any flavour is the wrong tool — you want a queue with a deadline.
Checking a schedule before you ship it
Compose the expression in the builder, read the next five run times, then mentally apply the offset between your browser's zone and the server's. If the two differ, write the intended zone into a comment above the crontab line — the next person to read it will not know what you meant otherwise.