timezone dst utc configuration

Cron and Timezones: UTC, Local Time, and DST Pitfalls

by Sinthuyan Arulselvam  · April 18, 2026

Cron does not know what time it is in London, New York, or Tokyo. It knows only what time the operating system tells it. That distinction is the source of an entire category of production incidents — missed backups, doubled billing runs, reports that arrive at 3 AM instead of 9 AM. This guide walks through every layer of the timezone problem, from bare-metal crontabs to managed cloud schedulers, and gives you the concrete rules to avoid the pitfalls.

The Default: System Timezone

When you write a crontab entry, cron reads the clock from the host operating system. No timezone offset. No IANA database lookup. Whatever date returns is what cron uses.

bash
# Check the system timezone on any Linux host
date
# Sat Jun 14 09:00:00 UTC 2025

# On systemd-based systems, get the full picture
timedatectl
# Local time: Sat 2025-06-14 09:00:00 UTC
# Universal time: Sat 2025-06-14 09:00:00 UTC
# RTC time: Sat 2025-06-14 09:00:00 UTC
# Time zone: UTC (UTC, +0000)
# NTP service: active

The critical detail for anyone running workloads on cloud infrastructure: most cloud VMs default to UTC. AWS EC2, Google Compute Engine, and Azure VMs all ship with UTC as the system timezone unless you explicitly change it. This is actually the sane default — more on that in a moment — but it catches teams off guard when they migrate from an on-premises server that was set to their local timezone.

On macOS or a local Linux workstation, the system timezone follows your locale settings. A crontab that works perfectly on your laptop may fire at completely wrong times in production. Always verify with timedatectl or date before assuming.

Two Schools of Thought: UTC Everywhere vs Local Time

The industry has largely converged on UTC as the standard for server infrastructure, but the debate still surfaces in teams that serve a single region. Here is an honest assessment of both approaches.

UTC Everywhere

Pros:

  • No DST transitions. UTC never shifts. A job scheduled for 02:30 UTC runs at 02:30 UTC every single day, including the days clocks spring forward or fall back.
  • Logs and monitoring correlate cleanly across services, regions, and vendors. Debugging a distributed incident is painful enough without converting timestamps between five different offsets.
  • Cloud infrastructure defaults match. You do not fight your platform.
  • Portable across deployments. The same crontab works identically on servers in Virginia, Frankfurt, and Singapore.

Cons:

  • Mental overhead when the business requirement is expressed in local time. "Run the payroll export every weekday at 9 AM London time" requires you to know the current UTC offset for London, account for DST, and remember to update if the business hours change.
  • Off-by-one-hour bugs after DST transitions when someone forgets to update a UTC-based schedule.

Local Time

Pros:

  • Schedule expressions match business language directly. 0 9 * * 1-5 literally means 9 AM on weekdays, no mental translation required.
  • Appropriate for single-region applications serving a single timezone where the business domain is inherently local.

Cons:

  • DST transitions cause real failures. During the spring-forward transition, the 02:00–03:00 hour does not exist. A job scheduled in that window simply does not run. During fall-back, the same hour occurs twice, potentially running a job twice.
  • Server migrations and cloud deployments frequently reset the system timezone, silently breaking schedules.

Recommendation: Use UTC everywhere for infrastructure jobs — backups, database maintenance, report generation, data pipelines. Use timezone-aware scheduling (CRON_TZ, systemd, Kubernetes spec.timeZone, or managed scheduler timezone support) for user-facing jobs where the business requirement is expressed in local time. Never rely on the system timezone of a cloud VM being anything other than UTC.

CRON_TZ: Per-Crontab Timezone in GNU cron

GNU cron (the standard on most Linux distributions via the cronie or vixie-cron packages) supports a CRON_TZ environment variable that overrides the system timezone for all subsequent entries in the crontab.

bash
# Run report at 9 AM London time regardless of server timezone
CRON_TZ=Europe/London
0 9 * * 1-5 /opt/reports/generate-daily.sh

# Switch timezone mid-file for a different job
CRON_TZ=America/New_York
0 9 * * 1-5 /opt/reports/generate-daily-ny.sh

# Restore UTC for infrastructure jobs
CRON_TZ=UTC
30 2 * * * /opt/backup/run-backup.sh

The timezone identifier must be a valid IANA timezone name — the same strings used in the TZ database, like America/Chicago, Asia/Tokyo, or Pacific/Auckland. Do not use abbreviations like EST or BST; they are ambiguous and not reliably supported.

BSD cron limitation: macOS and BSDs use a different cron implementation that does not honour CRON_TZ. On those systems, the only options are changing the system timezone or using a wrapper script that sets TZ before executing the job. This is another reason to prefer systemd timers or a managed scheduler in production environments.

Verification technique: After editing a crontab with CRON_TZ, add a test entry that runs one minute from now and logs the output of date. Confirm that the timestamp in the log matches your expected local time, not the server's system time.

bash
# Verification entry — run once, then remove
CRON_TZ=America/Chicago
* * * * * echo $(date) >> /tmp/cron-tz-verify.log 2>&1

Systemd Timers: Native Timezone Handling

Systemd timers are the strongest argument for migrating away from cron on modern Linux systems. The OnCalendar directive accepts an IANA timezone identifier directly in the time specification, and systemd handles DST transitions correctly at the kernel level.

text
# /etc/systemd/system/daily-report.timer
[Unit]
Description=Daily business report — London business hours

[Timer]
OnCalendar=Mon..Fri 09:00:00 Europe/London
Persistent=true

[Install]
WantedBy=timers.target

The Persistent=true directive is significant: if the system was off or the timer was missed (for example, during a DST gap), systemd will run the job immediately when the system next starts. This is the correct behaviour for most business-critical jobs.

Verify the next trigger time with:

bash
systemctl list-timers daily-report.timer
# NEXT                         LEFT     LAST                         PASSED  UNIT
# Mon 2025-06-16 09:00:00 BST  2d left  Fri 2025-06-13 09:00:01 BST  4m ago  daily-report.timer

Notice that systemd displays the next trigger in the timer's configured timezone (BST in this case), not in UTC. This makes auditing schedules dramatically easier.

Kubernetes 1.27+: spec.timeZone on CronJob

Kubernetes CronJob resources gained native timezone support in 1.27 (graduated to stable). The spec.timeZone field accepts any IANA timezone identifier.

yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-report
spec:
  schedule: "0 9 * * 1-5"
  timeZone: "America/New_York"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: reporter
              image: company/reporter:latest
          restartPolicy: OnFailure

With spec.timeZone, the cluster scheduler interprets the cron expression in the specified timezone. DST transitions are handled by the Go time package's IANA database, which is bundled with the kube-controller-manager binary.

Pre-1.27 workarounds: Before spec.timeZone was available, teams used two approaches. The first was to set the TZ environment variable on the container and perform the UTC offset calculation manually in the schedule expression, remembering to update it across DST transitions. The second was to set the system timezone of the nodes running the kube-controller-manager — a blunt instrument that affected all CronJobs on the cluster. Neither approach was satisfactory; upgrading to 1.27+ and using spec.timeZone is the correct path.

AWS EventBridge: Rules vs Scheduler

AWS has two distinct scheduling products with different timezone capabilities, and this distinction trips up many teams.

EventBridge Rules (the original product, formerly CloudWatch Events) support scheduled expressions in UTC only. There is no timezone parameter. If your business requirement is "9 AM EST on weekdays," you must calculate the UTC equivalent and update it twice a year for DST. Many teams have a sticky note next to their monitors reminding them to update EventBridge rules in March and November.

bash
# EventBridge Rule — UTC only, manual DST management required
# 9 AM EST (UTC-5) = 14:00 UTC
# 9 AM EDT (UTC-4) = 13:00 UTC
cron(0 14 ? * MON-FRI *)

EventBridge Scheduler (launched 2022) is the successor product and supports timezone-aware scheduling via the ScheduleExpressionTimezone parameter. This is the correct tool for any new schedule that needs to fire at a local time.

bash
# AWS CLI — create a timezone-aware schedule
aws scheduler create-schedule \
  --name daily-report \
  --schedule-expression "cron(0 9 ? * MON-FRI *)" \
  --schedule-expression-timezone "America/New_York" \
  --flexible-time-window Mode=OFF \
  --target '{"Arn": "...", "RoleArn": "..."}'

Migration path: Identify all EventBridge Rules that use a UTC offset to approximate local time. Create equivalent EventBridge Scheduler schedules with the correct ScheduleExpressionTimezone. Disable (do not delete) the old rules until you have confirmed the Scheduler is firing correctly. Then delete.

Google Cloud Scheduler: Native Timezone Support

Google Cloud Scheduler has supported timezone-aware cron expressions since its general availability launch. The timezone field accepts IANA identifiers and is a first-class configuration option in both the console and the API.

bash
# gcloud CLI
gcloud scheduler jobs create http daily-report \
  --schedule="0 9 * * 1-5" \
  --time-zone="Europe/London" \
  --uri="https://api.example.com/trigger/report" \
  --message-body=""

Cloud Scheduler's DST handling follows the same rule as all well-implemented timezone-aware schedulers: it uses the IANA timezone database to determine the UTC equivalent of each trigger time. During a DST spring-forward, the job simply skips the missing hour. During a fall-back, it fires once — not twice — at the wall-clock time.

One practical note: Google Cloud Scheduler has a minimum frequency of one invocation per minute, and it retries failed invocations up to five times with exponential backoff. For long-running jobs, make your HTTP handler idempotent — the retry behaviour means your job may be triggered more than once for a single scheduled time if the first attempt returns a non-2xx status.

DST-Safe Scheduling Rules

Daylight Saving Time causes two failure modes: skipped runs (spring-forward) and doubled runs (fall-back). These rules eliminate both.

Rule 1: Avoid the 1 AM – 3 AM Window

In most regions that observe DST, the clock change happens at 2:00 AM local time. The danger zone is roughly 1:00 AM to 3:00 AM. Any job scheduled in this window is at risk of being skipped or doubled, depending on the direction of the transition and the scheduler implementation.

If your job absolutely must run in the early morning, schedule it at 4:00 AM or later to provide a comfortable margin. For the equivalent UTC time, use the UTC offset for the non-DST half of the year (the more conservative offset) and accept the one-hour drift during the other half.

Rule 2: Test Across Transitions

DST bugs are notoriously hard to catch in normal testing because they only manifest twice a year. Use these techniques to test without waiting:

  • For systemd: use systemd-analyze calendar "Mon..Fri 09:00:00 Europe/London" to enumerate upcoming trigger times and manually verify the transition dates.
  • For application code: set the TZ environment variable to a timezone file and mock the clock using your language's time testing utilities.
  • For cloud schedulers: check the scheduler's execution history log in the days immediately after the DST transition date for your target region.

Rule 3: Monitor for Missed Runs

Every critical scheduled job should emit a heartbeat — a metric or log entry that signals "I ran successfully." A monitoring alert on the absence of that heartbeat within the expected window catches DST-related misses, system reboots, and deployment-related outages alike.

A dead man's switch pattern using a service like BetterUptime, Cronitor, or Healthchecks.io is the standard implementation: the job pings a URL at the end of each successful run, and the monitoring service alerts if no ping arrives within a configurable grace period.

Multi-Timezone Deployments

Running the same job at 9 AM business hours across multiple regions is a common requirement for global products. The naive approach — one cron entry per region with UTC offset math — becomes unmaintainable as the number of regions grows.

The correct architecture depends on your scheduler:

  • Kubernetes: Create one CronJob per region, each with its own spec.timeZone and a label identifying the target region. Use a Helm chart or Kustomize overlay to generate the region-specific resources from a single template, varying only the timezone and any region-specific configuration.
  • EventBridge Scheduler / Cloud Scheduler: Create one schedule per region with the appropriate ScheduleExpressionTimezone. Pass the region identifier as a parameter to the target function so the job knows which region it is processing.
  • Application-layer scheduling: Store schedules in a database table with a timezone column. A worker process queries for due jobs using proper timezone arithmetic (most database engines support AT TIME ZONE in SQL). This approach centralises the scheduling logic and makes it auditable without touching infrastructure configuration.

For the application-layer approach, the key query pattern is to convert the scheduled local time to UTC at query time, not at insert time. Storing a pre-computed UTC timestamp breaks at the next DST transition; storing the local time plus timezone identifier and computing UTC on the fly is always correct.

UTC Offset Quick Reference

The following table lists major business timezones with their standard (non-DST) and daylight saving time UTC offsets. DST dates are approximate and vary by year; always verify against the IANA database for precision scheduling.

Region / City IANA Identifier Standard Offset DST Offset DST Period (approx.)
UTC / Universal UTC +00:00 N/A No DST
London, UK Europe/London +00:00 (GMT) +01:00 (BST) Late Mar – Late Oct
Paris, Berlin, Amsterdam Europe/Paris +01:00 (CET) +02:00 (CEST) Late Mar – Late Oct
Helsinki, Kyiv Europe/Helsinki +02:00 (EET) +03:00 (EEST) Late Mar – Late Oct
Moscow, Russia Europe/Moscow +03:00 (MSK) N/A No DST since 2014
Dubai, UAE Asia/Dubai +04:00 (GST) N/A No DST
Karachi, Pakistan Asia/Karachi +05:00 (PKT) N/A No DST
Mumbai, Kolkata, India Asia/Kolkata +05:30 (IST) N/A No DST
Dhaka, Bangladesh Asia/Dhaka +06:00 (BST) N/A No DST
Bangkok, Jakarta Asia/Bangkok +07:00 (ICT) N/A No DST
Singapore, Beijing, Perth Asia/Singapore +08:00 (SGT) N/A No DST
Tokyo, Japan Asia/Tokyo +09:00 (JST) N/A No DST
Sydney, Melbourne (AEST) Australia/Sydney +10:00 (AEST) +11:00 (AEDT) Early Oct – Early Apr
Auckland, New Zealand Pacific/Auckland +12:00 (NZST) +13:00 (NZDT) Late Sep – Early Apr
Honolulu, Hawaii Pacific/Honolulu -10:00 (HST) N/A No DST
Anchorage, Alaska America/Anchorage -09:00 (AKST) -08:00 (AKDT) 2nd Sun Mar – 1st Sun Nov
Los Angeles, Seattle, SF America/Los_Angeles -08:00 (PST) -07:00 (PDT) 2nd Sun Mar – 1st Sun Nov
Denver, Phoenix area America/Denver -07:00 (MST) -06:00 (MDT) 2nd Sun Mar – 1st Sun Nov
Phoenix, Arizona America/Phoenix -07:00 (MST) N/A No DST
Chicago, Dallas, Houston America/Chicago -06:00 (CST) -05:00 (CDT) 2nd Sun Mar – 1st Sun Nov
New York, Miami, Boston America/New_York -05:00 (EST) -04:00 (EDT) 2nd Sun Mar – 1st Sun Nov
Toronto, Montreal America/Toronto -05:00 (EST) -04:00 (EDT) 2nd Sun Mar – 1st Sun Nov
São Paulo, Brazil America/Sao_Paulo -03:00 (BRT) -02:00 (BRST) Nov – Feb (southern hemisphere)
Buenos Aires, Argentina America/Argentina/Buenos_Aires -03:00 (ART) N/A No DST
Johannesburg, South Africa Africa/Johannesburg +02:00 (SAST) N/A No DST
Nairobi, Kenya Africa/Nairobi +03:00 (EAT) N/A No DST

A few notes on reading this table: Australia and New Zealand observe DST in reverse to the northern hemisphere — their summer (and therefore DST period) runs from approximately October to April. São Paulo's DST behaviour has been inconsistent in recent years; Brazil suspended DST in 2019 and it has not been reinstated as of this writing. Always verify current DST status for South American timezones against the IANA tzdata release notes before scheduling critical jobs.

Putting It Together: A Practical Checklist

Before deploying any scheduled job to production, run through this list:

  • Verify the execution environment timezone with timedatectl or date. Never assume.
  • Use IANA identifiers, not timezone abbreviations. America/New_York, not EST.
  • Prefer explicit timezone configuration (CRON_TZ, spec.timeZone, or scheduler timezone field) over UTC offset math that requires manual updates.
  • Avoid scheduling in the 1–3 AM window for any timezone that observes DST.
  • Add a heartbeat monitor to every critical scheduled job.
  • Document the intended local time in a comment next to every UTC-expressed cron schedule, so the next engineer knows what the number was supposed to mean.
  • Review all schedules after DST transitions in March/November (northern hemisphere) and October/April (southern hemisphere).

Timezone handling is one of those domains where the correct solution is not much harder than the wrong one — it just requires knowing which tool to reach for. A systemd timer with OnCalendar, a Kubernetes CronJob with spec.timeZone, or an EventBridge Scheduler with ScheduleExpressionTimezone all handle DST correctly without any manual intervention. The overhead of using these features is a few extra characters in a config file. The overhead of not using them is a missed payroll run at 3 AM on the second Sunday in March.

Explore related resources

Related seed schedules

Related Guides