Hey everyone ![]()
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
GENERIC_TIMEZONEset explicitly in the deployment (never inherited from the container)- Cron time expressed in the audience’s timezone, not mine
- First node logs actual execution time with zone; I check the first real run
- Any “yesterday / today / this week” date math uses the pinned timezone
- 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.