n8n Timezones and Schedule Triggers
A self-hosted n8n reads three timezone settings, so a schedule trigger can fire hours off. Set TZ, GENERIC_TIMEZONE and the workflow zone right.
Why your n8n schedule trigger fires at the wrong hour
An n8n schedule trigger fires at the wrong hour because n8n reads its timezone from three separate places, and fixing one of them fixes only part of the problem. The three places are the container's own TZ variable, the instance default GENERIC_TIMEZONE, and a timezone set inside an individual workflow. Set all three once and every schedule you write afterwards lands where you expect.
Correct one guess first. A fresh self-hosted n8n does not schedule in UTC (coordinated universal time). The container clock is UTC, because the official image sets no TZ. Scheduling is a separate layer, and n8n's documented default for GENERIC_TIMEZONE is America/New_York (as of August 2026), so an untouched instance fires its Schedule Triggers on New York time. That is why the offset people report rarely matches their own distance from UTC. An owner in Berlin who asks for 06:00 gets 12:00 local, and 11:00 during the weeks in March when the United States has moved to daylight saving time and Europe has not.
The three timezone layers, and which one wins
TZ is the operating system timezone inside the container. The n8n documentation describes it as the variable that sets the system timezone to control what scripts and commands like date return. It decides what date prints inside the container, what timestamp lands on a container log line, what new Date() returns in a Code node, and what any shell script you run in there sees. It has no effect on when a Schedule Trigger fires.
GENERIC_TIMEZONE is the n8n instance timezone. The documentation calls it the n8n instance timezone and notes that it is important for schedule nodes such as Cron. Cron here means the standard time-based scheduling syntax, and n8n exposes it as the Custom (Cron) option on the Schedule Trigger.
The workflow timezone is set per workflow. Open the workflow on the canvas, select the three dots in the upper right corner, select Settings, then change the Timezone value. It overrides GENERIC_TIMEZONE for that one workflow.
For a Schedule Trigger the order is fixed. n8n uses the workflow timezone if the workflow has one, otherwise the instance timezone from GENERIC_TIMEZONE, otherwise its built-in default of America/New_York. TZ is not consulted at any step of that decision.
For dates inside your nodes, the answer depends on which clock the code asks. Luxon, the date library behind n8n expressions, uses the n8n timezone, so $now and $today follow the same workflow-then-instance order as the trigger does. Plain JavaScript new Date() in a Code node asks the operating system, so it follows TZ. That split is the source of most of the confusion here: the trigger can be correct while every timestamp the workflow writes is hours out.
Set all three in the Compose file
Put TZ and GENERIC_TIMEZONE next to each other in the file so nobody sets one and forgets the other. The fragment below is the timezone-relevant part of a working service. The rest of the file, the reverse proxy and the certificate, comes from a self-hosted n8n on a VPS behind HTTPS.
services:
n8n:
image: docker.n8n.io/n8nio/n8n
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
environment:
- GENERIC_TIMEZONE=Europe/Berlin
- TZ=Europe/Berlin
- N8N_RUNNERS_ENABLED=true
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:Apply it with docker compose up -d, not with docker compose restart. A restart starts the same container again with the environment it was created with, so the file changes and the running process does not. up -d sees the changed environment and recreates the container. If you keep these values in an env file instead of inline, the same recreate rule applies, and the Compose env file and secrets handling guide covers where that file is read from.
Use an IANA (Internet Assigned Numbers Authority) zone name in Region/City form, such as Europe/Berlin or America/Sao_Paulo. Those names carry the daylight saving rules for that place, so the offset changes when the local clocks do. A fixed-offset name like Etc/GMT+5 never changes with the seasons, and its sign is inverted from what you would guess. Run LC_ALL=C TZ=Etc/GMT+5 date +%z and it prints -0500. Avoid those names.
Why setting only one of them leaves you half fixed
Set GENERIC_TIMEZONE alone and the Schedule Trigger fires at the hour you wanted, while everything that reads the operating system stays on UTC. A Code node calling new Date().toString() returns a UTC string, container log lines are stamped in UTC, and any file name built from the system clock rolls over at the wrong midnight.
Set TZ alone and the opposite happens. docker compose exec n8n date prints your local time, which looks like success, while the Schedule Trigger is still on America/New_York and fires six hours away from the hour you asked for. This is the version that costs the most time, because the check most people run first is the one that now passes.
Set a workflow timezone, then change GENERIC_TIMEZONE later, and that workflow ignores the change. The workflow value wins, and it keeps winning until someone opens that workflow's settings. One workflow running at a strange hour while its neighbours are fine is almost always this.
Check the clocks instead of guessing
Compare the host and the container directly.
date
docker compose exec n8n date
docker compose exec n8n printenv TZ GENERIC_TIMEZONEThe first two commands should print the same wall-clock time once TZ is set. printenv prints one line per variable that exists, so two lines of output means both are set, and one line means you are looking at the half-fixed state.
Now ask n8n itself, from inside a workflow, because a container shell cannot tell you what the workflow-level timezone is. Add a Code node to the workflow that misbehaves and run it once with Execute Workflow.
return [
{
json: {
n8n_time: $now.toISO(),
n8n_zone: $now.zoneName,
system_time: new Date().toString(),
},
},
];n8n_zone is the zone this workflow's Schedule Trigger will use, already resolved through the workflow-then-instance order, so it answers the question directly. system_time carries the container's own zone from TZ. Run it in the workflow that misbehaves rather than in a fresh one, because the workflow-level setting travels with the workflow. If those two values disagree, you have found the problem without opening a single config file.
Cron expressions in the Schedule Trigger node
The Schedule Trigger offers fixed intervals from seconds up to months, plus Custom (Cron) for anything those do not cover. The cron expression is read in the workflow's resolved timezone, so 0 6 * * * means 06:00 in that zone rather than 06:00 UTC. A five-field expression from crontab guru pastes in as is. n8n also accepts an optional seconds field, which the documentation's field table places first: second, minute, hour, day of month, month, day of week.
Never encode the offset by hand. Writing 0 4 * * * on a UTC instance to reach 06:00 in Berlin is correct in winter and one hour wrong all summer, because Berlin runs at UTC+1 in winter and UTC+2 in summer. Set the zone and write the local hour you actually mean.
What daylight saving does to a job set for 02:30
A local wall-clock time is not a guaranteed instant. Twice a year one hour disappears and one hour repeats, and any job scheduled inside those hours is affected. You can watch it happen with date on any Linux box, with no n8n involved.
LC_ALL=C TZ=Europe/Berlin date -d '2027-03-28 02:30'date: invalid date '2027-03-28 02:30'That is not a typo in the command. On 2027-03-28 the Berlin clock moves straight from 02:00 to 03:00, so 02:30 local does not exist that day, and date refuses to turn it into an instant. A job anchored to 02:30 local has no moment to run at. The neighbouring times are fine: date -d '2027-03-28 01:30' resolves as CET and date -d '2027-03-28 03:30' resolves as CEST.
The autumn transition is the mirror image. On 2027-10-31 the Berlin clock falls back from 03:00 to 02:00, so 02:30 happens twice.
LC_ALL=C TZ=Europe/Berlin date -d '2027-10-31 02:30 CEST' '+%s'
LC_ALL=C TZ=Europe/Berlin date -d '2027-10-31 02:30 CET' '+%s'1824942600
1824946200Two different instants, both of them called 02:30 local, 3600 seconds apart. A job pinned there either runs twice or runs once at an hour nobody picked, and neither result is what a billing run or a backup rotation wants. Move the schedule out of that window. In most European and North American zones the risky band is 00:00 to 03:00 local.
Schedule infrastructure in UTC and show local time to people
The standard answer separates the two jobs a timezone does. Machines need a stable interval. People need a readable hour.
- For work nobody watches, set the workflow timezone to UTC. Backups, cache warming, log shipping and report generation belong here. In UTC the gap between two runs is exactly the gap you wrote, on every day of the year, because UTC has no daylight saving.
- For work a person reads, keep the schedule in UTC and convert at the point of display. One expression does it:
{{ $now.setZone('Europe/Berlin').toFormat('yyyy-MM-dd HH:mm') }}puts local time in the message body while the trigger stays stable.
The same split applies outside n8n. When part of your automation runs as a systemd service and timer on the VPS, its OnCalendar line is read in the system timezone, which is a fourth clock with its own setting. Keeping every scheduler on UTC leaves you one rule to remember instead of four. It matters for anything that summarises a period too, because an n8n AI agent workflow asked for yesterday's numbers will quietly use a different 24 hours depending on which zone resolved.
Failure modes and the output you will see
Everything fires about six hours off. GENERIC_TIMEZONE was never set, so the built-in default America/New_York applies. docker compose exec n8n printenv GENERIC_TIMEZONE prints nothing at all. Set it, then recreate the container.
You edited the Compose file and nothing changed. You ran docker compose restart, so the container kept its original environment. Run docker compose up -d, then confirm with docker compose exec n8n printenv TZ.
The trigger is right and the timestamps are wrong. Only GENERIC_TIMEZONE is set. A new Date() in a Code node still reads UTC from the operating system. Set TZ to the same value and recreate.
One workflow ignores the instance setting. That workflow carries its own timezone in its settings, which wins over GENERIC_TIMEZONE. Open the canvas, three dots, Settings, Timezone.
A daily job ran twice, or skipped a day, once this year. Its scheduled hour sits inside a daylight saving transition. Move the hour, or move that workflow to UTC.
FAQ
Why does my n8n schedule trigger fire at the wrong hour?
The workflow is resolving a different timezone than you assume. n8n picks the workflow timezone if the workflow has one, otherwise the instance timezone from GENERIC_TIMEZONE, otherwise its built-in default of America/New_York. A self-hosted instance where nobody set GENERIC_TIMEZONE schedules on New York time, not on UTC, which is why the offset rarely matches your own distance from UTC. Run docker compose exec n8n printenv GENERIC_TIMEZONE. No output means it was never set.
What is the difference between TZ and GENERIC_TIMEZONE in n8n?
TZ is the operating system timezone inside the container. It controls what date returns inside the container, what timestamps appear on container log lines, what new Date() returns in a Code node, and what any script you run in there sees. GENERIC_TIMEZONE is the n8n instance timezone, and it is what schedule nodes and Luxon expressions such as $now use. Setting one without the other gives you a correct trigger with wrong timestamps, or correct timestamps with a trigger that fires hours away. Set both to the same value.
Should I set the workflow timezone or GENERIC_TIMEZONE?
Set GENERIC_TIMEZONE as the default for the whole instance, and use the per-workflow setting only where one workflow genuinely belongs to another zone. The workflow value wins over the instance value, and it does not follow later changes to GENERIC_TIMEZONE, so a forgotten per-workflow override is hard to track down months later.
What happens to a job scheduled at 02:30 when the clocks change?
That local time either disappears or occurs twice. LC_ALL=C TZ=Europe/Berlin date -d '2027-03-28 02:30' returns date: invalid date '2027-03-28 02:30', because the Berlin clock jumps from 02:00 to 03:00 that day. On 2027-10-31 the same wall-clock reading maps to two instants an hour apart. Keep scheduled work out of the 00:00 to 03:00 local band, or set the workflow to UTC and convert to local time only where a person reads it.