A cron expression contains no time zone. It is five numbers, and the machine, container or managed service the scheduler runs on decides which clock they are measured against. The 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, which is 04:00 or 05:00 in New York and 19:00 or 20:00 in Sydney, depending on daylight saving.
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 on the target system before relying on it.
2. TZ inside the command
This is portable and explicit at the point of use, but it affects only how the command interprets time. It does not change 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. The job will then drift by an hour whenever 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 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 handle this case differently:
- 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 do not fire during the hour that does not exist, so you lose an hour's worth of 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, because UTC has no skipped hour. 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 occurs twice. - Some minimal crons make no adjustment at all and will run a fixed-time job twice. If double execution would be harmful, fix that in the job itself rather than in the schedule.
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, for example by keying writes on a business date, taking an advisory lock, or checking whether the work is already done. Once the job is idempotent, 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 covers 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. Use UTC for jobs whose timing depends on the machine rather than on people, such as backups, compaction, cache warming, log shipping and data pipeline steps. For those jobs, the "wrong" local hour a run lands on in summer does not matter.
Local time is worth the extra complexity only when a person 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. Do not use a fixed offset like UTC+1, which by definition cannot follow the transitions.
Use UTC for machine-facing schedules and a named IANA zone for human-facing ones. In both cases, keep jobs out of the 01:00-03:00 local window, which is where every transition edge case falls.
Containers and managed schedulers
Containers add a separate problem: 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. Setting TZ=Europe/Amsterdam has no effect if the zone database is missing. In that case 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. The setting is declarative and visible in the manifest, and it 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. Use a queue with a deadline instead.
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. Without it, the next person to read the line will not know what you meant.