Timezone-safe cron: TZ=UTC in /etc/crontab vs in a per-user crontab vs inside the script — picking the layer that matters

I once had a cron job that was supposed to send a “good morning” digest at 7 AM. It went out at 12:30 AM for two weeks before anyone complained. The server was in UTC. The cron line said 0 7 * * *. The script’s internal “is it morning?” check used date, which read the system timezone. The system timezone was set to Asia/Kolkata. The cron daemon ignored that. The script honoured it. The output was wrong by 5.5 hours and only on every-second-day’s content.

This is the layered model of timezone in cron, why three places matter, and the order I now set them in to keep this kind of bug out of my life.

Three layers, three behaviors

  • Layer 1 — When cron fires. The cron daemon (cron, cronie, or crond) reads the system timezone at startup and uses it to interpret 0 7 * * *. If the system was in UTC when cron started, 0 7 means 7 AM UTC, even if you later change /etc/timezone to Asia/Kolkata — until you restart cron.
  • Layer 2 — What the cron environment looks like. When cron forks the job, the child inherits cron’s environment. $TZ is usually unset there, so the script reads system timezone via tzset() at runtime. This is live with the current /etc/timezone.
  • Layer 3 — What the script does internally. Calls to date, language libraries (Python’s datetime.now(), Node’s new Date()), file timestamps — all see whatever timezone the process inherited from layer 2.

The bug above lived in the gap between layer 1 and layer 2. cron interpreted 0 7 in its own timezone (which had been set to UTC at boot), but the script ran date +%H against IST. The two never agreed.

The rule I follow now

Set everything to UTC. System timezone, cron timezone, every script’s runtime timezone — UTC across the board. Convert to local time only at the edges (output to logs, output to user-facing emails). The rationale is simple: when the timezone never varies, the timezone bug class disappears.

The setup:

# 1. System timezone — UTC, persistent
sudo timedatectl set-timezone UTC

# 2. Restart cron so it picks up the new TZ
sudo systemctl restart cron

# 3. Add TZ=UTC at the top of /etc/crontab and per-user crontabs
# (this guarantees Layer 2, even if /etc/timezone gets accidentally
# changed and cron isn't restarted)
TZ=UTC
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Then your jobs:
0 7 * * * /usr/local/bin/morning-digest.sh

The TZ=UTC at the top of the crontab is the key line — it sets $TZ in cron’s environment, which is then inherited by every job. This makes layer 2 explicit and guards against the system timezone changing without cron getting restarted.

If you genuinely need local time

Some jobs really need to fire at “9 AM India time” regardless of UTC. The right way is to keep the system in UTC and put the timezone in the per-job line, not the system:

# Wrong: setting TZ on the cron schedule line
TZ=Asia/Kolkata 0 9 * * * /usr/local/bin/morning.sh
# (cron does NOT honor a TZ prefix on the schedule line itself)

# Right: convert in your head, or use a per-job env override
30 3 * * * TZ=Asia/Kolkata /usr/local/bin/morning.sh

The 30 3 * * * is UTC; 3:30 UTC = 9:00 IST. The TZ=Asia/Kolkata on the same line is set as an environment variable for the spawned process, so any date calls inside morning.sh see IST. The cron daemon itself is still firing in UTC. Two timezones, both explicit, neither implicit.

The “DST is real and it bites” gotcha

If your cron schedule lives in a DST-observing timezone, the day clocks fall back you’ll either run the 1-2 AM job twice, or skip it entirely on spring-forward day. cron has no idea about DST transitions; it just blindly fires whenever the wall clock matches the schedule.

This is another reason to keep cron in UTC. UTC has no DST. Schedule jobs against the unmoving reference; if you need a local-time semantic, the script can do the conversion inside.

The defensive pattern in the script itself

For the cron jobs that matter, my scripts now log their interpretation of “now” up front:

#!/bin/bash
set -euo pipefail
echo "Run start: $(date -u +'%Y-%m-%dT%H:%M:%SZ') (UTC)"
echo "Local: $(date +'%Y-%m-%dT%H:%M:%S %Z') (TZ=$TZ)"
echo "Hostname: $(hostname)"
# ... actual work ...

Three lines. Catches the next bug-of-this-class within a few runs of the log instead of after two weeks of customer complaints.

Quick verification

# What's the system timezone?
timedatectl

# What does cron think the timezone is?
sudo grep TZ /etc/crontab /var/spool/cron/crontabs/* 2>/dev/null

# What does cron's environment actually look like?
crontab -l | head
* * * * * env > /tmp/cron-env.txt
# wait one minute, check /tmp/cron-env.txt

The third one is the test that catches reality. Whatever env shows is what your scripts actually see.

If you have a fleet, set every server to UTC, restart cron, add TZ=UTC to every crontab, and convert in scripts. The bugs that live between cron’s interpretation, the system’s timezone, and the script’s view of “now” all evaporate when there’s only one answer to “what time is it?”

Cover photo: Pixabay on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.