# Cron Timezone: CRON_TZ, TZ, and Jobs That Run at the Wrong Time

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/cron-timezone

> Cron runs jobs in the time zone of the cron daemon. Check and change it, use CRON_TZ, set zones in containers, and fix jobs that run at the wrong time.

**Cron runs jobs in the time zone of the cron daemon: the `TZ` variable in the daemon's own environment if it has one, otherwise the system zone in `/etc/localtime`.** Check it with `timedatectl`. To run some jobs in another zone, cronie (Fedora, RHEL, Arch) reads a `CRON_TZ=` line; Debian's cron and BusyBox crond have no equivalent, and a `TZ=` line in a crontab never moves the schedule in any of them. A job that runs exactly as many hours off as your UTC offset is almost always following a schedule written for a different zone.

This guide covers cronie 1.7.2, Debian's cron 3.0pl1-197 (Debian 13) and the versions Ubuntu ships, and BusyBox 1.37.0 as shipped in Alpine 3.24, all checked October 24, 2026. For the expression syntax itself, see [cron expression syntax](https://cronradar.com/blog/cron-expression).

## Cron Uses the Daemon's Zone, Not Yours

The daemon converts the system clock to local time with the C library's rules, so its zone is whatever the C library sees in the daemon's process. A cronie maintainer puts it as "the timezone set by the TZ variable with which is the crond process started or the TZ of the local time of the system if TZ is not set" ([cronie issue #192](https://github.com/cronie-crond/cronie/issues/192)). Debian's [cron(8)](https://manpages.debian.org/trixie/cron/cron.8.en.html) says the same about `/etc/localtime`. Running `date` in your own shell proves nothing, because your login environment can set `TZ`.

Check what the daemon sees:

```bash
timedatectl                                 # "Time zone:" and "System clock synchronized:"
readlink -f /etc/localtime                  # e.g. /usr/share/zoneinfo/Etc/UTC
pid=$(pgrep -o -x crond || pgrep -o -x cron)
sudo cat /proc/"$pid"/environ | tr '\0' '\n' | grep '^TZ='  # no output: it follows /etc/localtime
ps -o lstart= -p "$pid"                     # when the daemon started
ls -l --time-style=full-iso /etc/localtime  # when the zone link last changed
```

A `TZ` in the daemon's environment overrides `/etc/localtime`. The usual source is the service's environment file: [cronie's systemd unit](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/contrib/cronie.systemd) loads `/etc/sysconfig/crond` and [Debian's](https://sources.debian.org/src/cron/3.0pl1-197/debian/cron.service/) loads `/etc/default/cron`. cronie writes log times in the system zone even for jobs scheduled in another zone ([crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html)), so compare run times in one zone, for example with `journalctl --utc`.

### Restart Cron After Changing the Zone

`timedatectl set-timezone` replaces the `/etc/localtime` link ([timedatectl(1)](https://www.freedesktop.org/software/systemd/man/latest/timedatectl.html)). Whether a running daemon notices depends on the implementation:

- **cronie** (1.7.2, and the same code in 1.5.7) computes its offset from UTC at startup and recomputes it only when daylight saving starts or ends ([`set_time()` in cron.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L611-L625)). A switch from UTC to Asia/Tokyo goes unnoticed until a restart.
- **Debian's cron** had the same code until 3.0pl1-152. [Bug #1019716](https://bugs.debian.org/1019716), a daily job running an hour late after a server moved zones, led to a [patch](https://sources.debian.org/src/cron/3.0pl1-197/debian/patches/fixes/Check_for_timezone_changes.patch/) that recomputes the offset every minute. Debian 12 (3.0pl1-162), Debian 13, and Ubuntu 24.04 (3.0pl1-184ubuntu2) have it; Debian 11 (3.0pl1-137) and Ubuntu 22.04 (3.0pl1-137ubuntu3) don't ([Launchpad](https://launchpad.net/ubuntu/+source/cron)).
- **BusyBox crond on Alpine** uses musl, which reads `/etc/localtime` once and reuses it as long as `TZ` is unchanged ([`__tz.c`](https://git.musl-libc.org/cgit/musl/tree/src/time/__tz.c?h=v1.2.6#n135)).

Restart after any zone change. Both the cronie and Debian units set `KillMode=process`, so a restart leaves running jobs alone:

```bash
sudo timedatectl set-timezone Etc/UTC
sudo systemctl restart crond    # Fedora and RHEL
sudo systemctl restart cronie   # Arch
sudo systemctl restart cron     # Debian and Ubuntu
```

## Run One Job in Another Zone

| Setting           | cronie 1.7.2                                  | Debian cron 3.0pl1-197                     | BusyBox crond 1.37.0 |
| ----------------- | --------------------------------------------- | ------------------------------------------ | -------------------- |
| `CRON_TZ=...`     | Sets the schedule's zone for entries below it | Passed to the commands; schedule unchanged | Ignored              |
| `TZ=...`          | Passed to the commands; schedule unchanged    | Passed to the commands; schedule unchanged | Ignored              |
| Daemon's own `TZ` | Not passed to jobs                            | Not passed to jobs                         | Passed to jobs       |

### cronie: CRON_TZ

cronie matches entries against the wall clock of the zone named in `CRON_TZ` ([crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html)). The man page calls it the zone "for the cron table", but each entry copies the environment as it stands when that line is read ([user.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/user.c#L118-L165)), so `CRON_TZ` covers the entries below it, and another `CRON_TZ` line switches again:

```text
# Entries above any CRON_TZ line use the daemon's zone.
0 3 * * * /opt/app/cleanup.sh

CRON_TZ=America/New_York
0 9 * * 1-5 /opt/app/morning-report.sh

CRON_TZ=Asia/Tokyo
30 8 * * * /opt/app/tokyo-sync.sh
```

Three details catch people:

- **Commands still see the system zone.** `CRON_TZ` reaches the job as an ordinary variable, but `date` reads `TZ`. Add `TZ=America/New_York` beside `CRON_TZ` if the script prints or computes local times.
- **Nothing checks the name.** cronie sets `TZ` to the value and asks the C library for local time, and a name glibc can't find falls back to UTC ([tzset.c](https://github.com/bminor/glibc/blob/glibc-2.41/time/tzset.c#L321-L330)). Neither `crontab` nor `crond` reports it, so a typo or a missing tzdata package gives you a UTC schedule.
- **Use a location, not an abbreviation.** In the tz database, `EST` is a link to America/Panama, which has no daylight saving time, so `CRON_TZ=EST` runs an hour late all summer ([backward](https://data.iana.org/time-zones/tzdb/backward)). Debian 13 moved legacy names such as `EST`, `US/Eastern`, and `Japan` (the name in crontab(5)'s own example) into [tzdata-legacy](https://packages.debian.org/trixie/tzdata-legacy), so without that package they mean UTC.

### Debian and Ubuntu: No CRON_TZ

Debian's [crontab(5)](https://manpages.debian.org/trixie/cron/crontab.5.en.html) says the daemon "does not support per-user timezones", and that a `TZ` set in a crontab "will affect only the commands executed in the crontab, not the execution of the crontab tasks themselves." Like any [cron environment variable](https://cronradar.com/blog/cron-environment-variables), it changes what the job sees, not when it starts. Two ways around it:

Schedule the job at both candidate UTC hours and let the command check the local hour. 09:00 in New York is 13:00 UTC in summer and 14:00 UTC in winter, so on a UTC server:

```text
0 13,14 * * 1-5 [ "$(TZ=America/New_York date +\%H)" = 09 ] && /opt/app/morning-report.sh
```

Exactly one of the two runs passes the check on any day, including the days the clocks change. Cron turns an unescaped `%` into a newline, hence `\%`.

Or use a systemd timer, which has accepted a zone in calendar expressions since systemd 235 ([systemd.time(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html)): `OnCalendar=Mon..Fri 09:00 America/New_York`. Run `systemd-analyze calendar --iterations=3 'Mon..Fri 09:00 America/New_York'` to see the next runs before you install it.

### BusyBox crond: One Zone for Everything

BusyBox crond recognizes only `MAILTO=`, `SHELL=`, and `PATH=` lines. Any other assignment, `CRON_TZ=` and `TZ=` included, has fewer than six fields and is skipped without a warning ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L462-L547)). The whole daemon runs in the zone from its own `TZ` or `/etc/localtime`, and jobs inherit that environment.

## Containers: Set the Zone in the Image

A container has its own zone but not its own clock. Linux time namespaces offset only the monotonic and boot-time clocks, so wall-clock time always comes from the host ([time_namespaces(7)](https://man7.org/linux/man-pages/man7/time_namespaces.7.html)). If the image sets no zone, the zone is UTC: with no `TZ` and no `/etc/localtime`, both glibc and musl fall back to UTC ([glibc](https://github.com/bminor/glibc/blob/glibc-2.41/time/tzset.c#L409-L420), [musl](https://git.musl-libc.org/cgit/musl/tree/src/time/__tz.c?h=v1.2.6#n188)). For most jobs that's what you want.

If the whole container must follow a zone, two things go wrong:

- **No tzdata.** `debian:trixie-slim` includes it; `ubuntu:24.04` and `alpine:3.24` don't ([package lists](https://github.com/docker-library/repo-info/tree/master/repos), checked October 24, 2026). Without the zone files, `TZ=America/New_York` silently means UTC.
- **Only `ENV TZ` on a Debian-based image.** cronie and Debian's cron don't hand the daemon's `TZ` to jobs, so the daemon schedules in New York time while `date` inside the job prints UTC. Link the zone instead, so both read it: `RUN ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime`.

On Alpine, `ENV TZ` works, because BusyBox crond passes its environment to jobs. This image runs `/etc/crontabs/root` in New York time:

```dockerfile
FROM alpine:3.24
RUN apk add --no-cache tzdata curl
# BusyBox crond reads TZ from its environment and passes it to every job.
ENV TZ=America/New_York
COPY crontab /etc/crontabs/root
CMD ["crond", "-f", "-d", "8"]
```

`-d 8` logs to stderr, so `docker logs` shows each run.

## Other Schedulers Set the Zone Per Schedule

Hosted schedulers and job frameworks take a zone per schedule, and most default to UTC (checked October 24, 2026):

| Scheduler                                                                                                                     | Default zone                         | Set a zone with                   | Watch for                                                                                     |
| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------- | --------------------------------------------------------------------------------------------- |
| [Kubernetes CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#time-zones)                         | kube-controller-manager's local zone | `.spec.timeZone` (stable in 1.27) | `CRON_TZ` or `TZ` inside `schedule` fails validation                                          |
| [GitHub Actions](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule)   | UTC                                  | `timezone:` beside `cron:`        | A time skipped by spring-forward runs at the next valid time                                  |
| [EventBridge Scheduler](https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#time-zones)                | UTC                                  | `ScheduleExpressionTimezone`      | Skipped times don't run, repeated times run once; legacy scheduled rules have no zone setting |
| [Cloud Scheduler](https://docs.cloud.google.com/scheduler/docs/reference/rest/v1/projects.locations.jobs)                     | UTC                                  | `timeZone` (`--time-zone`)        | Google recommends UTC to avoid daylight saving anomalies                                      |
| [Azure Functions timer](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#ncrontab-time-zones) | UTC                                  | `WEBSITE_TIME_ZONE` app setting   | Not supported on Linux Consumption or Flex Consumption                                        |
| [Hangfire 1.8](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/RecurringJobOptions.cs)                  | UTC                                  | `RecurringJobOptions.TimeZone`    | Each server resolves the stored zone ID itself                                                |
| [Celery beat 5.6](https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#time-zones)                                | UTC, or Django's `TIME_ZONE`         | `timezone` setting, for the app   | django-celery-beat must be reset by hand after a zone change                                  |

The [Kubernetes CronJob guide](https://cronradar.com/blog/kubernetes-cronjob-monitoring) and [Hangfire cron time zones](https://cronradar.com/blog/hangfire-cron-time-zones) cover those two in depth. For the zone settings of three others, see [Azure Functions timer triggers](https://cronradar.com/blog/azure-functions-timer-trigger), [Google Cloud Scheduler](https://cronradar.com/blog/google-cloud-scheduler), and [Celery beat troubleshooting](https://cronradar.com/blog/celery-beat-not-running).

## Why a Cron Job Runs at the Wrong Time

The size of the error usually names the cause:

| The job is off by                                 | Likely cause                                                                                                                                                           | Check                                                            |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Exactly the UTC offset (4 or 5 hours in New York) | A schedule written for one zone running in another: a UTC expression copied from a hosted scheduler, a container on UTC, or a `TZ=` line expected to move the schedule | The daemon's zone and `TZ`, with the commands above              |
| The gap between an old and a new zone             | The daemon kept its old offset after a zone change                                                                                                                     | Daemon start time against the `/etc/localtime` change; restart   |
| One hour, since a clock-change weekend            | Daylight saving: the job follows a zone with DST and you compare against UTC, or the reverse; or a fixed zone such as `EST`                                            | The zone name in use                                             |
| A few minutes, and growing                        | The clock isn't synchronized                                                                                                                                           | `System clock synchronized:` and `NTP service:` in `timedatectl` |
| Right time, extra days                            | Both day fields are restricted, and Linux cron runs when either matches                                                                                                | Day-of-month and day-of-week fields                              |
| Missing or doubled on a clock-change night        | A job scheduled between 01:00 and 03:00 local time                                                                                                                     | See below                                                        |

### Daylight Saving Time

For jobs in the daemon's own zone, cronie and Debian's cron special-case clock changes of under three hours: after a spring-forward they run the fixed-time jobs that fell in the skipped interval, and after a fall-back they don't repeat them ([cronie cron(8)](https://man7.org/linux/man-pages/man8/cron.8.html), [Debian cron(8)](https://manpages.debian.org/trixie/cron/cron.8.en.html)). That protection doesn't cover `CRON_TZ`. A comment in cronie's matching loop says jobs in a job-specific zone can be missed or run twice when that zone changes its clocks, and recommends not scheduling them in that hour ([cron.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L538-L542)). BusyBox matches each minute's local time with no special case ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L889-L928)), so a job in the skipped hour doesn't run and one in the repeated hour runs twice. [Cron and daylight saving time](https://cronradar.com/blog/cron-daylight-saving-time) covers the transitions in depth.

## Run Servers in UTC; Put Local Zones on the Job

UTC never changes its offset, so a UTC server has no skipped or repeated hours, and its logs line up with the hosted schedulers that default to UTC. It also keeps cronie out of a code path that holds back `CRON_TZ` jobs while the system zone itself is changing its clocks ([cron.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L585-L592)).

When a job must follow local business hours, put the zone on that job, not on the server: `CRON_TZ` on cronie, the hour check or a systemd timer on Debian and Ubuntu, and the scheduler's own setting elsewhere. Use a location name such as `America/New_York`, and keep zone-specific jobs out of 01:00 to 03:00 local time.

## Monitor the Schedule in UTC

CronRadar evaluates every schedule in UTC, and an application's time zone setting doesn't change when a run is expected ([schedule syntax](https://docs.cronradar.com/schedules)). Convert the job's local schedule before you send it. GNU `date` shows the UTC time for a summer and a winter date:

```bash
date -u --date='TZ="America/New_York" 2026-07-15 09:00' +%H:%M   # 13:00
date -u --date='TZ="America/New_York" 2026-01-15 09:00' +%H:%M   # 14:00
```

If the two differ, the job's zone has daylight saving time, and no single five-field UTC expression follows it. CronRadar's rule is to send the earlier UTC time, with a grace period of at least an hour plus the job's usual duration. For the 09:00 New York report, which takes up to ten minutes, that's `0 13 * * 1-5` with `gracePeriod=4200`. If the conversion crosses midnight, shift the day fields too: 22:00 on weekdays in New York is `0 2 * * 2-6` in UTC.

There's no CronRadar SDK for cron and no auto-discovery, so each job reports with its own HTTP check-in. This wrapper reports start, runs the report, then reports complete or fail and exits with the report's own code. Each request gives up after five seconds and ignores errors, so monitoring can't fail the job:

```bash
#!/bin/sh
# /opt/app/morning-report-monitored.sh
# 09:00 New York is 13:00 UTC in summer and 14:00 UTC in winter:
# send the earlier time, with a grace period of 1 hour plus a 10-minute run.
# The key lives in a file only the job's account can read: cronie and Debian's cron
# copy crontab variables into the headers of the mail they send.
CRONRADAR_API_KEY=$(cat /etc/cronradar/api-key 2>/dev/null)
url="https://cron.life/api/v1/ping/morning-report"
run_id="morning-report-$(date +%s)-$$"

report() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$CRONRADAR_API_KEY:" \
    --data-urlencode "schedule=0 13 * * 1-5" \
    --data-urlencode "gracePeriod=4200" \
    --data-urlencode "run_id=$run_id" \
    "$@" "$url/$event" || true
}

report start
/opt/app/morning-report.sh
rc=$?
if [ "$rc" -eq 0 ]; then
  report complete
else
  report fail --data-urlencode "message=morning-report.sh exited with code $rc"
fi
exit "$rc"
```

Schedule the wrapper instead of the report:

```text
CRON_TZ=America/New_York
0 9 * * 1-5 /opt/app/morning-report-monitored.sh
```

Every check-in carries the schedule, so the first run creates the monitor and a deleted monitor comes back on the next run. A `fail` report alerts immediately, without waiting for the grace period.

The rule has a cost. In summer, a missed report goes `critical` 70 minutes after it was due, where the same job on a UTC schedule could use a grace period of about 10 minutes. In winter, when the report starts at 14:00 UTC, the monitor shows `warning` for the hour before each run. A job scheduled in UTC has neither problem. For Hangfire or Quartz.NET jobs on a non-UTC zone, compare CronRadar's next expected run with the scheduler's next fire time.

## Frequently Asked Questions

### What time zone does cron use?

The zone of the cron daemon's process: its `TZ` environment variable if set, otherwise the system zone in `/etc/localtime`. `timedatectl` shows the system zone, and `/proc/<pid>/environ` shows whether the daemon has a `TZ` of its own. The zone in your login shell doesn't matter.

### How do I set a time zone for one cron job?

On cronie, put `CRON_TZ=America/New_York` on the line above the entry; it applies to every entry below it until the next `CRON_TZ` line. Debian, Ubuntu, and BusyBox have no per-job zone. There, schedule the job at both candidate UTC hours with a check on the local hour, or move it to a systemd timer with the zone in `OnCalendar`.

### Do I need to restart cron after changing the time zone?

Usually, yes. cronie recomputes its UTC offset only when daylight saving starts or ends, Debian 11 and Ubuntu 22.04 behave the same way, and BusyBox on Alpine reads the zone once. Debian 12 and later and Ubuntu 24.04 notice within a minute, but restarting is harmless there too, because running jobs aren't killed.

### Why is my cron job running at the wrong time?

Compare the error with your UTC offset. An error equal to the offset means the schedule was written for a different zone than the daemon uses. An hour that began on a clock-change weekend points to daylight saving or a fixed zone like `EST`, and a few minutes points to an unsynchronized clock.

### Does TZ in a crontab change the schedule?

No. In cronie and Debian's cron, `TZ=` only sets the variable for the commands, so `date` inside the job changes but the run time doesn't. BusyBox ignores the line entirely. On cronie, `CRON_TZ` is the variable that moves the schedule.
