DEV Community

delay.run
delay.run

Posted on

Cron job timezone problems: how to run cron in a specific time zone (and survive DST)

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
Enter fullscreen mode Exit fullscreen mode
               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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Avoid scheduling local-time jobs between 1:00 and 3:00 am.
  2. If a job must run exactly once a day, make it idempotent: check whether today's run already happened before doing the work.
  3. 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
Enter fullscreen mode Exit fullscreen mode

Check how systemd reads it with:

systemd-analyze calendar "Mon..Fri *-*-* 09:00:00 Europe/Berlin"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode
0 9 * * * /usr/local/bin/job.sh >> /var/log/job.log 2>&1
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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_TZ works in some implementations, not all. Check man 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)