SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

n8n Schedule Trigger chạy sai giờ: sửa 3 timezone

n8n đọc TZ, GENERIC_TIMEZONE và timezone của workflow. Nếu để mặc định America/New_York, Schedule Trigger có thể lệch 6 đến 9 giờ, nhất là khi đổi DST.

Vì sao Schedule Trigger của n8n chạy sai giờ

Schedule Trigger của n8n chạy sai giờ vì n8n đọc múi giờ từ 3 nơi riêng biệt, và sửa một nơi chỉ giải quyết được một phần vấn đề. Ba nơi đó là biến TZ của chính container, giá trị mặc định GENERIC_TIMEZONE của instance và múi giờ được đặt trong từng workflow. Hãy đặt cả 3 một lần để mọi schedule bạn tạo sau đó chạy đúng thời điểm mong muốn.

Trước tiên, hãy sửa một giả định thường gặp. n8n self-hosted mới không lập lịch theo UTC (giờ phối hợp quốc tế). Đồng hồ của container là UTC vì image chính thức không đặt TZ. Lập lịch là một lớp riêng, và giá trị mặc định được n8n tài liệu hóa cho GENERIC_TIMEZONEAmerica/New_York (tính đến tháng 8 năm 2026), vì vậy instance chưa được cấu hình sẽ chạy Schedule Trigger theo giờ New York. Đây là lý do độ lệch mà người dùng báo cáo hiếm khi khớp với khoảng cách của họ so với UTC. Một quản trị viên ở Berlin yêu cầu chạy lúc 06:00 sẽ nhận được 12:00 theo giờ địa phương, và 11:00 trong những tuần của tháng 3 khi Hoa Kỳ đã chuyển sang giờ mùa hè còn châu Âu thì chưa.

Ba lớp múi giờ và lớp nào được ưu tiên

TZ là múi giờ của hệ điều hành bên trong container. Tài liệu n8n mô tả đây là biến đặt múi giờ hệ thống để kiểm soát giá trị mà các script và lệnh như date trả về. Nó quyết định date in ra gì bên trong container, timestamp nào được ghi vào mỗi dòng log của container, new Date() trả về gì trong Code node và mọi shell script bạn chạy bên trong đó nhìn thấy gì. Biến này không ảnh hưởng đến thời điểm Schedule Trigger chạy.

GENERIC_TIMEZONE là múi giờ của instance n8n. Tài liệu gọi đây là múi giờ của instance n8n và lưu ý rằng nó quan trọng đối với các node lập lịch như Cron. Cron ở đây là cú pháp lập lịch theo thời gian tiêu chuẩn. n8n cung cấp cú pháp này dưới dạng tùy chọn Custom (Cron) trong Schedule Trigger.

Múi giờ của workflow được đặt riêng cho từng workflow. Mở workflow trên canvas, chọn ba dấu chấm ở góc trên bên phải, chọn Settings, rồi thay đổi giá trị Timezone. Thiết lập này ghi đè GENERIC_TIMEZONE đối với workflow đó.

Đối với Schedule Trigger, thứ tự ưu tiên là cố định. n8n dùng múi giờ của workflow nếu workflow đã đặt múi giờ. Nếu chưa đặt, n8n dùng múi giờ của instance từ GENERIC_TIMEZONE. Nếu giá trị đó cũng không có, n8n dùng giá trị mặc định tích hợp là America/New_York. TZ không được kiểm tra ở bất kỳ bước nào trong quyết định này.

Đối với ngày giờ bên trong các node, kết quả phụ thuộc vào clock mà code yêu cầu. Luxon, thư viện ngày giờ đứng sau các expression của n8n, dùng múi giờ n8n. Vì vậy, $now$today tuân theo cùng thứ tự workflow trước, instance sau như trigger. JavaScript thuần new Date() trong Code node yêu cầu hệ điều hành cung cấp thời gian, nên tuân theo TZ. Sự tách biệt này là nguyên nhân của phần lớn nhầm lẫn: trigger có thể chạy đúng, trong khi mọi timestamp workflow ghi ra đều bị lệch vài giờ.

Đặt cả ba giá trị trong file Compose

Đặt TZGENERIC_TIMEZONE cạnh nhau trong file để không ai đặt một giá trị rồi quên giá trị còn lại. Đoạn bên dưới là phần liên quan đến timezone của một service đang hoạt động. Phần còn lại của file, gồm reverse proxy và certificate, được lấy từ hướng dẫn tự host n8n trên VPS phía sau 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:

Áp dụng thay đổi bằng docker compose up -d, không dùng docker compose restart. Lệnh restart khởi động lại chính container đó với các biến môi trường tại thời điểm container được tạo, nên thay đổi trong file không được áp dụng cho process đang chạy. up -d nhận thấy environment đã thay đổi và tạo lại container. Nếu bạn lưu các giá trị này trong env file thay vì khai báo trực tiếp, quy tắc tạo lại container vẫn áp dụng. Hướng dẫn xử lý env file và secrets trong Compose giải thích file đó được đọc từ đâu.

Dùng tên zone của IANA (Internet Assigned Numbers Authority) theo dạng Region/City, chẳng hạn Europe/Berlin hoặc America/Sao_Paulo. Các tên này chứa quy tắc daylight saving của địa điểm đó, nên offset sẽ thay đổi khi giờ địa phương thay đổi. Tên có offset cố định như Etc/GMT+5 không bao giờ thay đổi theo mùa, và dấu của nó bị đảo ngược so với cách bạn có thể đoán. Chạy LC_ALL=C TZ=Etc/GMT+5 date +%z để xem kết quả -0500. Tránh dùng các tên đó.

Vì sao chỉ đặt một trong hai khiến hệ thống chỉ được sửa một nửa

Đặt riêng GENERIC_TIMEZONE khiến Schedule Trigger chạy đúng giờ bạn muốn, trong khi mọi thành phần đọc thời gian từ hệ điều hành vẫn dùng UTC. Code node gọi new Date().toString() trả về chuỗi UTC, các dòng log của container được ghi timestamp theo UTC, còn mọi tên file tạo từ system clock sẽ chuyển sang ngày mới vào sai thời điểm nửa đêm.

Đặt riêng TZ thì ngược lại. docker compose exec n8n date in ra giờ địa phương, trông như đã thành công, trong khi Schedule Trigger vẫn dùng America/New_York và chạy lệch 6 giờ so với giờ bạn yêu cầu. Đây là trường hợp tốn nhiều thời gian nhất, vì bước kiểm tra mà đa số mọi người chạy đầu tiên nay lại cho kết quả đúng.

Đặt timezone cho workflow rồi thay đổi GENERIC_TIMEZONE sau đó sẽ không ảnh hưởng đến workflow đó. Giá trị của workflow được ưu tiên và tiếp tục được ưu tiên cho đến khi có người mở phần cài đặt của workflow đó. Nếu một workflow chạy vào giờ bất thường trong khi các workflow bên cạnh vẫn bình thường thì gần như luôn là do trường hợp này.

Kiểm tra clock thay vì đoán

So sánh trực tiếp host và container.

date
docker compose exec n8n date
docker compose exec n8n printenv TZ GENERIC_TIMEZONE

Hai lệnh đầu tiên phải in ra cùng một giờ thực tế sau khi đã đặt TZ. printenv in một dòng cho mỗi biến đang tồn tại, nên 2 dòng output nghĩa là cả hai biến đã được đặt, còn 1 dòng nghĩa là bạn đang gặp trạng thái mới chỉ sửa được một nửa.

Tiếp theo, hãy hỏi trực tiếp n8n từ bên trong workflow, vì shell của container không thể cho biết timezone ở cấp workflow. Thêm một Code node vào workflow đang gặp lỗi và chạy node đó một lần bằng Execute Workflow.

return [
  {
    json: {
      n8n_time: $now.toISO(),
      n8n_zone: $now.zoneName,
      system_time: new Date().toString(),
    },
  },
];

n8n_zone là zone mà Schedule Trigger của workflow này sẽ sử dụng. Giá trị này đã được xác định theo thứ tự workflow trước, instance sau, nên trả lời trực tiếp câu hỏi. system_time chứa zone riêng của container, lấy từ TZ. Hãy chạy node trong workflow đang gặp lỗi thay vì một workflow mới, vì thiết lập ở cấp workflow đi cùng workflow đó. Nếu hai giá trị này khác nhau, bạn đã xác định được vấn đề mà không cần mở bất kỳ file cấu hình nào.

Biểu thức Cron trong node Schedule Trigger

Schedule Trigger cung cấp các khoảng thời gian cố định từ vài giây đến vài tháng, cùng tùy chọn Custom (Cron) cho những khoảng thời gian không được hỗ trợ. Biểu thức cron được đọc theo timezone đã được xác định của workflow, vì vậy 0 6 * * * nghĩa là 06:00 theo timezone đó, không phải 06:00 UTC. Bạn có thể dán nguyên biểu thức 5 trường từ crontab guru. n8n cũng chấp nhận trường giây tùy chọn. Theo bảng trường trong tài liệu, trường này đứng đầu: giây, phút, giờ, ngày trong tháng, tháng, ngày trong tuần.

Không tự mã hóa offset. Việc viết 0 4 * * * trên một instance dùng UTC để chạy lúc 06:00 ở Berlin là đúng vào mùa đông nhưng sai 1 giờ trong suốt mùa hè, vì Berlin dùng UTC+1 vào mùa đông và UTC+2 vào mùa hè. Hãy đặt timezone rồi ghi giờ địa phương bạn thực sự muốn.

Chuyển giờ mùa hè ảnh hưởng thế nào đến job đặt lúc 02:30

Thời gian theo đồng hồ địa phương không đảm bảo tương ứng với một thời điểm duy nhất. Mỗi năm hai lần, một giờ biến mất và một giờ lặp lại. Mọi job được đặt trong các khoảng giờ đó đều bị ảnh hưởng. Bạn có thể quan sát điều này bằng date trên bất kỳ máy Linux nào, không cần n8n.

LC_ALL=C TZ=Europe/Berlin date -d '2027-03-28 02:30'
date: invalid date '2027-03-28 02:30'

Lệnh này không bị gõ nhầm. Vào 2027-03-28, đồng hồ Berlin chuyển thẳng từ 02:00 sang 03:00. Vì vậy, 02:30 theo giờ địa phương không tồn tại trong ngày đó và date từ chối chuyển nó thành một thời điểm cụ thể. Job gắn với 02:30 theo giờ địa phương không có thời điểm để chạy. Các thời điểm liền kề vẫn hợp lệ: date -d '2027-03-28 01:30' được xác định là CET và date -d '2027-03-28 03:30' được xác định là CEST.

Chuyển giờ vào mùa thu là trường hợp ngược lại. Vào 2027-10-31, đồng hồ Berlin lùi từ 03:00 về 02:00. Vì vậy, 02:30 xảy ra hai lần.

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
1824946200

Đó là hai thời điểm khác nhau, đều được gọi là 02:30 theo giờ địa phương và cách nhau 3600 giây. Job được cố định vào thời điểm này có thể chạy hai lần hoặc chạy một lần vào một giờ không ai chọn. Cả hai kết quả đều không phù hợp với job tính phí hoặc xoay vòng backup. Hãy chuyển lịch chạy ra khỏi khoảng giờ này. Ở phần lớn các múi giờ tại châu Âu và Bắc Mỹ, khoảng giờ rủi ro là từ 00:00 đến 03:00 theo giờ địa phương.

Lập lịch hạ tầng theo UTC và hiển thị giờ địa phương cho người dùng

Cách làm chuẩn là tách hai nhiệm vụ mà timezone xử lý. Máy cần một khoảng thời gian ổn định. Con người cần một giờ dễ đọc.

  • Với các tác vụ không cần người theo dõi, hãy đặt timezone của workflow là UTC. Backup, làm nóng cache, chuyển log và tạo báo cáo thuộc nhóm này. Với UTC, khoảng cách giữa hai lần chạy luôn đúng bằng khoảng thời gian bạn đã cấu hình, vào mọi ngày trong năm, vì UTC không áp dụng daylight saving.
  • Với các tác vụ do con người đọc, hãy giữ lịch theo UTC và chuyển đổi tại thời điểm hiển thị. Một expression là đủ: {{ $now.setZone('Europe/Berlin').toFormat('yyyy-MM-dd HH:mm') }} chèn giờ địa phương vào nội dung message trong khi trigger vẫn ổn định.

Cách tách này cũng áp dụng ngoài n8n. Khi một phần automation chạy dưới dạng systemd service và timer trên VPS, dòng OnCalendar của nó được đọc theo timezone của system, tức là một đồng hồ thứ tư có cấu hình riêng. Giữ mọi scheduler theo UTC giúp bạn chỉ cần nhớ một quy tắc thay vì bốn quy tắc. Điều này cũng quan trọng với mọi tác vụ tổng hợp theo khoảng thời gian, vì workflow n8n AI agent được yêu cầu lấy số liệu của ngày hôm qua sẽ âm thầm dùng 24 giờ khác nhau tùy theo zone được dùng để phân giải.

Các lỗi thường gặp và output bạn sẽ thấy

Mọi tác vụ đều chạy lệch khoảng sáu giờ. GENERIC_TIMEZONE chưa từng được thiết lập, nên giá trị mặc định tích hợp sẵn America/New_York được áp dụng. docker compose exec n8n printenv GENERIC_TIMEZONE không in ra bất kỳ nội dung nào. Hãy thiết lập biến này, rồi tạo lại container.

Bạn đã sửa file Compose nhưng không có gì thay đổi. Bạn đã chạy docker compose restart, nên container vẫn giữ environment ban đầu. Hãy chạy docker compose up -d, rồi xác nhận bằng docker compose exec n8n printenv TZ.

Trigger hoạt động đúng nhưng timestamp bị sai. Chỉ GENERIC_TIMEZONE được thiết lập. new Date() trong một Code node vẫn đọc UTC từ hệ điều hành. Hãy đặt TZ thành cùng giá trị, rồi tạo lại container.

Một workflow không áp dụng thiết lập của instance. Workflow đó có timezone riêng trong phần settings, và thiết lập này được ưu tiên hơn GENERIC_TIMEZONE. Mở canvas, chọn dấu ba chấm, Settings, Timezone.

Một job hằng ngày chạy hai lần hoặc bỏ qua một ngày, nhưng chỉ xảy ra một lần trong năm nay. Giờ chạy được đặt vào khoảng thời gian chuyển đổi daylight saving. Hãy đổi giờ chạy hoặc chuyển workflow đó sang UTC.

FAQ

Vì sao trigger schedule của n8n chạy sai giờ?

Workflow đang dùng một timezone khác với timezone bạn nghĩ. n8n chọn timezone của workflow nếu workflow đã được đặt timezone; nếu chưa, nó dùng timezone của instance từ GENERIC_TIMEZONE; nếu vẫn chưa có, nó dùng giá trị mặc định tích hợp là America/New_York. Một instance self-hosted không được đặt GENERIC_TIMEZONE sẽ lên lịch theo giờ New York, không phải UTC. Vì vậy độ lệch thường không khớp với chênh lệch múi giờ của bạn so với UTC. Chạy docker compose exec n8n printenv GENERIC_TIMEZONE. Không có output nghĩa là giá trị này chưa từng được đặt.

TZ và GENERIC_TIMEZONE trong n8n khác nhau thế nào?

TZ là timezone của hệ điều hành bên trong container. Nó quyết định date trả về gì bên trong container, timestamp nào xuất hiện trên các dòng log của container, new Date() trả về gì trong Code node và mọi script chạy trong container nhìn thấy gì. GENERIC_TIMEZONE là timezone của n8n instance. Đây là timezone mà các schedule node và biểu thức Luxon như $now sử dụng. Nếu chỉ đặt một trong hai, trigger có thể chạy đúng nhưng timestamp sai, hoặc timestamp đúng nhưng trigger chạy lệch nhiều giờ. Hãy đặt cả hai về cùng một giá trị.

Nên đặt timezone của workflow hay GENERIC_TIMEZONE?

Đặt GENERIC_TIMEZONE làm mặc định cho toàn bộ instance. Chỉ dùng setting riêng của workflow khi workflow đó thực sự thuộc một timezone khác. Giá trị của workflow được ưu tiên hơn giá trị của instance. Nó cũng không tự cập nhật khi GENERIC_TIMEZONE thay đổi sau đó. Vì vậy, một override riêng của workflow bị bỏ quên có thể rất khó truy ra sau vài tháng.

Điều gì xảy ra với job được lên lịch lúc 02:30 khi đồng hồ đổi giờ?

Mốc giờ địa phương đó có thể biến mất hoặc xuất hiện hai lần. LC_ALL=C TZ=Europe/Berlin date -d '2027-03-28 02:30' trả về date: invalid date '2027-03-28 02:30' vì đồng hồ Berlin nhảy từ 02:00 sang 03:00 vào ngày đó. Vào 2027-10-31, cùng một mốc giờ trên đồng hồ sẽ tương ứng với hai thời điểm cách nhau một giờ. Tránh lên lịch công việc trong khoảng 00:00 đến 03:00 theo giờ địa phương, hoặc đặt workflow dùng UTC rồi chỉ chuyển đổi sang giờ địa phương tại nơi người dùng đọc kết quả.