A cron job that runs 0 9 * * * doesn't run at "9 AM" — it runs at 9 AM in whatever timezone the server happens to be in, which may differ from the timezone you're thinking in, the timezone your users are in, and the timezone your database timestamps are in
The previous articles on this site covered cron expression basics, production scheduled job best practices, how the cron daemon works (OS scheduling vs systemd), and DST interaction with cron. This article addresses timezone-aware scheduling — why "run this at 9 AM" is incomplete without specifying a timezone, and the specific patterns that create cascading timezone confusion in production job systems.
The server timezone: the hidden dependency
Every cron job runs in the timezone of the server executing it. The cron daemon reads system time from the OS, and /etc/localtime (or the TZ environment variable) determines which timezone that is.
Common default server timezones:
- Most cloud provider defaults: UTC — AWS EC2, Google Cloud Compute, Azure VMs typically default to UTC unless explicitly changed
- VPS providers: varies; some default to the server's geographic location
- Shared hosting: often matches the hosting provider's data center location
The timezone assumption problem: a developer in London sets up a cron job 0 9 * * * thinking "this will run at 9 AM" — intending 9 AM GMT/BST in London. If the server is in UTC, this works in winter (UTC = GMT). In summer (when the UK observes BST = UTC+1), the job still runs at UTC 9:00 = BST 10:00. The job has moved one hour later in the local context, but nobody explicitly noticed because the cron expression didn't change.
The multi-timezone application: when "same time for everyone" isn't
Applications serving users across timezones often need jobs to run "at a specific local time" for each user or region — or "at a specific UTC time" regardless of local clocks.
"Send the daily digest email at 8 AM":
- If this means 8 AM UTC: all users globally receive it at the same absolute moment, but at very different local times (8 AM London, 3 AM New York, 4 PM Tokyo)
- If this means 8 AM in the user's local timezone: the job must either run continuously (checking every minute for users whose 8 AM just arrived) or run once per timezone offset (one job for UTC-8, one for UTC-7, etc.)
The first interpretation is technically simpler. A single 0 8 * * * UTC cron job handles it. Whether "8 AM UTC" makes sense for user-facing communications depends on where your users are — fine if they're all in Europe, poor if they're globally distributed.
The second interpretation requires rethinking the cron approach entirely — per-timezone jobs, or a job queue with scheduled delivery time per recipient, are more appropriate than a single cron expression.
Database timestamps vs cron schedules: the silent desync
A cron job that queries "records created in the last 24 hours" has an implicit assumption: that the database timestamps and the cron schedule are in the same timezone reference.
Example desync scenario:
- Database stores
created_atas UTC timestamps - Cron job runs at
0 2 * * *server local time (which is UTC-5 = 07:00 UTC) - Query:
WHERE created_at > NOW() - INTERVAL 24 HOURS
This seems fine — but NOW() in the query returns the database server's current time, which may or may not be UTC, depending on database configuration. If the database's NOW() returns local time and the cron job's interpretation of "the last 24 hours" is based on UTC, records spanning the timezone boundary are either double-counted or missed.
The robust pattern: store all timestamps as UTC, configure databases to return UTC from NOW() / CURRENT_TIMESTAMP, and schedule cron jobs in UTC. Consistency across all three layers eliminates the desync.
Cron in Docker containers: another timezone layer
Docker containers have their own timezone configuration, independent of the host:
- Default container timezone: UTC (most Linux container base images)
- Containers inherit
TZenvironment variable if set and passed in - Container timezone can differ from host timezone
A cron job running inside a Docker container that assumes "the host timezone" will be wrong if the container defaults to UTC and the host is in a different timezone. The fix: explicitly set TZ=UTC (or the appropriate timezone) in the container's environment, or use a consistent timezone strategy across all layers.
Kubernetes CronJobs: the schedule interpretation
Kubernetes CronJobs specify schedules in the same cron format — and they execute in UTC by default (Kubernetes system time is UTC):
spec:
schedule: "0 9 * * *" # This is 9 AM UTC
For applications deployed to Kubernetes where "9 AM" means a specific local time, the schedule needs to be adjusted for the UTC offset — 0 8 * * * for London in winter (UTC+0), 0 7 * * * for London in summer (UTC+1/BST).
Kubernetes 1.25+ supports a timezone field:
spec:
schedule: "0 9 * * *"
timeZone: "Europe/London" # Handles DST automatically
This is the cleaner solution — specifying the timezone explicitly rather than hardcoding the UTC-offset-adjusted time.
How to use the Cron Explainer on sadiqbd.com
- Read cron expressions in UTC mental model: assume the expression is in UTC unless you know the specific server timezone —
0 9 * * *means 9 AM UTC, which may or may not be 9 AM in the timezone you care about - For DST-affected schedules: the previous article covered the DST problem; use Kubernetes
timeZonefield, systemd timerOnCalendarwith--time-zone, or convert to UTC with DST awareness built in - For timezone-aware application scheduling: cron expressions are the wrong tool for per-user-timezone scheduling; consider a job queue with scheduled_for timestamps (stored in UTC) and a dispatcher that continuously processes due jobs
Frequently Asked Questions
Is there a way to make cron timezone-aware without rewriting the job infrastructure?
For system cron, yes — by setting the TZ environment variable at the crontab level. Many cron implementations (including the widely used Vixie cron) support a CRON_TZ variable at the top of a crontab that sets the timezone for all expressions in that file:
CRON_TZ=America/New_York
0 9 * * * /path/to/script.sh
This makes 0 9 * * * run at 9 AM Eastern Time (including DST adjustment), regardless of the system's overall timezone setting. Not all cron implementations support this — check your specific implementation's documentation. For Kubernetes, the timeZone spec field (1.25+) is the equivalent.
Is the Cron Explainer free? Yes — completely free, no sign-up required.
Try the Cron Explainer free at sadiqbd.com — translate any cron expression into plain English, with timezone context.