My 9 AM workflow fired at 1 AM — the Schedule Trigger timezone gotcha (and the fix)

Hey everyone :waving_hand:

Sharing a real production lesson from running client n8n workflows — the kind of bug that works perfectly on your laptop and only breaks in production.

The 1 AM email blast nobody asked for

I shipped a daily digest workflow: every morning at 9 AM, pull yesterday’s metrics, have an LLM write a short summary, email it to the client. Worked perfectly for weeks.

Then the client pinged me: “Why did we get yesterday’s digest at 1 AM?”

I hadn’t changed anything. The Schedule Trigger said 9:00. The executions list said 9:00. And yet the email landed at 1:00 AM their time. A “good morning” summary, delivered at 1 AM. Not my finest moment.

The two clocks you’re not looking at

There are (at least) two timezones in play with every scheduled workflow, and most of us only think about one:

1. The server timezone. n8n evaluates Schedule Trigger cron expressions in the timezone of the machine (or container) it’s running on. On your laptop that’s your local timezone — 9:00 means 9:00 your time, everything works. In production your n8n is very likely a Docker container with TZ=UTC. Same cron, same trigger, now 9:00 means 9:00 UTC — 1:00 AM for my client (UTC−8).

2. The workflow’s own date math. Inside the workflow, $now and the Date & Time node use the server clock too. My digest computed “yesterday’s metrics” at runtime. Run at 1 AM local, “yesterday” was still correct by the server clock — but the upstream metrics rollup hadn’t finished yet, so the summary was built on stale data. Wrong time and wrong data, from one timezone slip.

That’s why it only strikes in production: dev (laptop, local TZ) and prod (container, UTC) quietly disagree, and nothing in n8n warns you.

The fix: pin the timezone, then verify it

Two parts — config and a verification habit.

Part 1 — pin GENERIC_TIMEZONE. n8n has an env var that controls the timezone for Schedule Triggers and the Date & Time node. Unset, it falls back to the server timezone. I now set it explicitly in every deployment:

GENERIC_TIMEZONE=Asia/Shanghai

One line. The Schedule Trigger evaluates cron in that timezone regardless of what the container thinks. (Multi-audience? A workflow gets one evaluation timezone — pin to the audience’s zone, use separate triggers for others.)

Part 2 — verify with the first real execution. I don’t trust config alone anymore. Every scheduled workflow I ship starts with a guard node that logs the actual execution time with zone:

{{ $now.toFormat('yyyy-MM-dd HH:mm z') }}

First real run writes its actual local time into the execution log. If it doesn’t say what I expect, I catch it on day one — not via a client at 1 AM. Thirty seconds to add, and it’s saved me twice.

The DST bonus round

If your pinned zone observes daylight saving, cron can double-fire or skip on transition days (2:30 AM happens twice, or never). For exactly-once daily runs, I avoid the 1:00–3:00 AM window in DST zones. Boring, but “ran twice on spring-forward Sunday” is not a bug report you want.

The checklist I now run for every scheduled workflow

  1. GENERIC_TIMEZONE set explicitly in the deployment (never inherited from the container)
  2. Cron time expressed in the audience’s timezone, not mine
  3. First node logs actual execution time with zone; I check the first real run
  4. Any “yesterday / today / this week” date math uses the pinned timezone
  5. No daily trigger between 1:00–3:00 AM in a DST-observing zone

Timezones are “obviously fine” until a client reads your morning digest at 1 AM. Pin it, log it, check the first run.


More of these production patterns (with importable JSON): github.com/zhp910318/n8n-ai-automation-lab — MIT licensed.

Great production lesson. I’d also recommend explicitly validating the workflow timezone and checking upstream data readiness before sending scheduled reports. For critical workflows, idempotency and execution-time logging can help catch unexpected runs and prevent duplicate notifications. Timezone configuration is a small detail that can have a big impact in production.

Logging the first scheduled run is a useful check. One correction for readers: the Schedule Trigger uses the workflow timezone first, then the instance timezone. Setting the workflow timezone explicitly is especially useful when clients are in different zones on the same instance. The documented self-hosted default is America/New_York, rather than automatically inheriting the container timezone.

Also, 09:00 UTC is 17:00 in UTC+8, not 01:00. It may be worth checking the offsets in the example so readers can reproduce the mismatch.

For the digest, I would record the reporting window separately from the execution time. For example, a late retry should still report on the originally intended day rather than silently moving “yesterday” forward.

Timezone precedence is documented here: Schedule Trigger | Nodes | n8n Docs

Good catches — thanks, all three points are fair.

You’re right on the arithmetic: 09:00 UTC is 17:00 at UTC+8, not 01:00 — the example as written doesn’t reproduce. Fixed the post above: the 1 AM version of this bug happens for audiences west of the evaluation zone (9:00 evaluated in UTC → 1:00 at UTC−8). The mechanism is the real lesson either way: the cron is evaluated in a zone nobody configured on purpose.

Also right on the GENERIC_TIMEZONE default: unset, it defaults to America/New_York, not the container TZ. That’s arguably a worse footgun than what I described — an unset instance then disagrees with both your dev laptop and your UTC container. Pinning it explicitly is the fix either way; I should have stated the default correctly.

And agreed on the per-workflow timezone setting: it’s more surgical than my “separate triggers” aside when one instance serves multiple client zones. Workflow setting first, GENERIC_TIMEZONE as fallback.

The reporting-window-vs-execution-time point is solid too. My guard node logs the actual execution time; it should also log the intended window computed from the pinned zone, so a late retry doesn’t silently slide “yesterday” forward. Adding that to my checklist.

Thanks — the upstream-data-readiness point is the one that bit me hardest. The guard node I described logs actual execution time with zone, which catches the timezone slip on day one; adding an upstream-freshness check (is the rollup actually complete for the window?) before the LLM summary step would have caught the stale-data half of my incident. For scheduled runs, execution-time logging also doubles as an idempotency/dedup key on retries.

Glad that helped! The key is to validate both the reporting window and data readiness before generating the summary. Great discussion — thanks for sharing the production lesson!

Thanks Shital — glad that landed. To close the loop on how I implemented your two-pronged point: I split it into two guard nodes instead of one. The first logs the intended reporting window, computed from the pinned timezone, before anything runs; the second checks upstream freshness (is the rollup actually complete for that window?) right before the summary step. Split this way, a stale-data failure and a timezone slip can’t masquerade as each other in the execution logs. Appreciate the discussion.

That’s a solid improvement! Separating the reporting-window and data-freshness guards makes failures much easier to diagnose. Thanks for sharing how you implemented it — great production lesson. :+1:

I’d use the intended reporting window to identify the email, not the execution timestamp. A retry has a new timestamp, but it’s still the same report for that client and day.

If the email provider accepts the message and the delivery-status write fails, I’d check that uncertain send before trying again

Good point — and it generalizes: the intended window should be the identity for everything downstream, not just the email subject. In my implementation I compute one deterministic key at the start, {client_id}:{window_date}, from the pinned timezone, and use it as the dedup guard, the email’s idempotency key, and the outbox row key. A retry then can’t mint a second identity for the same report no matter when it actually runs.

Your uncertain-send point is the nastier half. The failure mode is send-succeeded-but-ack-lost, and a blind retry is how the client gets the digest twice. The fix is send-then-verify: before any retry, query the provider’s sent API — or your own outbox table keyed by that window id — for “did this window already go out?” Only re-send on a definitive no. In n8n that’s an IF node right after the failed send; cheap, and it turns the retry from a guess into a check.

One more wrinkle worth pinning down: derive the window from the schedule config, not the execution time. Manual executions and retries run “now”, so window-from-execution-time silently re-identifies the report. The window has to come from the same pinned-zone calculation every time, regardless of when the run actually fires.