Cron in GitHub Actions and Kubernetes: the UTC Pitfall
Updated 2026.10.03 · 5 min read
GitHub Actions runs cron schedules in UTC by default and Kubernetes uses the controller's time zone. Learn the 5-field syntax, time zone fields and the OR rule.
Open Cron Expression ParserA cron expression carries no time zone; the platform that runs it decides. GitHub Actions evaluates schedule in UTC by default, and a Kubernetes CronJob without a time zone uses the local time zone of the kube-controller-manager. So 0 9 * * 1-5 means 09:00 UTC on GitHub, not 09:00 where you live. Either convert the time to UTC yourself or set the platform’s time zone field. To read an expression in plain words and list its next runs in UTC or your own zone, paste it into the Cron Expression Parser.
The five fields
Both platforms use the classic five-field form:
┌──────── minute 0-59
│ ┌────── hour 0-23
│ │ ┌──── day of month 1-31
│ │ │ ┌── month 1-12
│ │ │ │ ┌ day of week 0-6 (0 = Sunday)
* * * * *
| Operator | Meaning | Example |
|---|---|---|
* |
Every value | * * * * * runs every minute |
, |
List | 0 9,18 * * * runs at 09:00 and 18:00 |
- |
Range | 0 9 * * 1-5 runs at 09:00 Monday to Friday |
/ |
Step | */15 * * * * runs every 15 minutes |
POSIX defines only the asterisk, single numbers, ranges and lists. Step values with / are an extension from Vixie cron, which both GitHub Actions and Kubernetes document as supported. Expressions with six fields (seconds first) or seven (Quartz) belong to other schedulers.
Some reference results, evaluated in the Asia/Seoul time zone:
| Expression | Starting from | Next runs |
|---|---|---|
0 9 * * 1-5 |
Friday 2026-10-02 10:00 | Mon 10-05 09:00, then 10-06, 10-07, 10-08 and 10-09 at 09:00 |
*/15 * * * * |
2026-10-02 10:07 | 10:15, 10:30, 10:45, 11:00, 11:15 |
0 0 31 2 * |
Any time | Never, because February has no 31st |
0 25 * * * |
Any time | Invalid: the hour field must be 0 to 23 |
How the two platforms differ
As of October 2026, the official documentation describes the following:
| Item | GitHub Actions schedule |
Kubernetes CronJob |
|---|---|---|
| Default time zone | UTC | Local time zone of the kube-controller-manager |
| Setting a time zone | timezone key with an IANA name |
.spec.timeZone with an IANA name (stable since v1.27) |
Macros such as @daily |
Not supported | @yearly, @monthly, @weekly, @daily, @hourly |
| Step values | Supported | Supported |
| Names | JAN-DEC, SUN-SAT |
sun to sat for the day of week |
Question mark ? |
Not listed | Same meaning as * |
| Shortest interval | Once every 5 minutes | Not stated; the documentation’s example runs every minute |
Kubernetes also notes that putting CRON_TZ= or TZ= inside .spec.schedule is not supported and fails validation. Use the time zone field.
The UTC pitfall in numbers
Seoul and Tokyo are both UTC+9, with no daylight saving time. Suppose you commit 0 9 * * 1-5 to a GitHub workflow at 10:00 on Friday, 2 October 2026, local time, expecting a 09:00 start on weekdays. Evaluated in UTC, the first run is that same Friday at 09:00 UTC, which is 18:00 in Seoul and Tokyo, and every later run is at 18:00 too.
To run at 09:00 local time you subtract nine hours:
| Goal (UTC+9) | Expression in UTC | Note |
|---|---|---|
| 09:00, Monday to Friday | 0 0 * * 1-5 |
00:00 UTC is 09:00 the same day |
| 08:00, Monday to Friday | 0 23 * * 0-4 |
23:00 UTC is the previous day, so the weekdays shift too |
| 08:00, Monday to Friday | 0 23 * * 1-5 |
Wrong: this runs Tuesday to Saturday at 08:00 |
The second and third rows are the part people miss. When the conversion crosses midnight, the day-of-week and day-of-month fields have to move with it. Some schedules cannot be converted at all: “00:00 on the 1st, local time” is 15:00 UTC on the last day of the previous month, and five-field cron has no way to say “last day”.
Declaring the time zone avoids the arithmetic. Following the documented syntax:
# GitHub Actions
on:
schedule:
- cron: "0 9 * * 1-5"
timezone: "Asia/Seoul"
# Kubernetes CronJob
spec:
schedule: "0 9 * * 1-5"
timeZone: "Asia/Seoul"
On Kubernetes this matters even if your cluster happens to behave today: with a managed control plane you may not know which time zone the kube-controller-manager uses, so set .spec.timeZone explicitly. For zones that do observe daylight saving time, a schedule written in UTC moves by an hour in local terms twice a year. GitHub documents that with timezone set, a schedule that falls in the skipped hour of a spring-forward transition advances to the next valid time; its example is a 2:30 AM schedule that advances to 3:00 AM.
Day of month and day of week: either one matches
POSIX specifies that when the day of month (or the month) and the day of week are both restricted, a day matching either one is matched. The two fields are combined with OR, not AND.
0 0 13 * 5 therefore does not mean “Friday the 13th”. Starting from Friday 2026-10-02 10:00 in Asia/Seoul, it runs at 00:00 on Friday 10-09, Tuesday 10-13 and Friday 10-16: every Friday and also the 13th.
GitHub’s documentation says to use POSIX cron syntax. The Kubernetes page links to the format of the Go library robfig/cron, whose implementation matches a day when either field matches as long as neither is an asterisk. If you need “the 13th, only when it is a Friday”, schedule one of the two conditions and test the other inside the job. The Cron Expression Parser points out this rule when both fields are set.
Other behavior worth knowing
GitHub Actions
- Scheduled runs can be delayed when load is high, and the documentation names the start of every hour as a high-load time; if load is high enough, some queued jobs may be dropped. Choose a minute other than 0, such as
17 0 * * *. - Scheduled workflows run on the latest commit of the default branch, and the workflow file must exist on that branch.
- In a public repository, scheduled workflows are disabled automatically when there has been no repository activity for 60 days.
Kubernetes
- A CronJob creates a Job approximately once per scheduled time. The documentation warns that in some circumstances two Jobs, or none, may be created, so Jobs should be idempotent.
concurrencyPolicydecides what happens when the previous Job is still running:Allow(the default),ForbidorReplace.startingDeadlineSecondssets how late a missed Job may still start. If more than 100 schedules were missed, the controller does not start the Job and logs an error.
Checking a schedule with ZEKILO Dev
- Open the Cron Expression Parser and paste the expression. The format is detected from the number of fields.
- Set the time zone to UTC to see what GitHub Actions does by default, or to Asia/Seoul or Asia/Tokyo to see what you intended.
- Compare the description and the next five run times with what you expect.
The expression is parsed in your browser and is not sent to a server.
Summary
- Cron has no time zone of its own. GitHub Actions defaults to UTC; Kubernetes defaults to the controller’s local zone.
- Convert to UTC carefully, moving the day fields when the time crosses midnight, or set
timezone(GitHub) or.spec.timeZone(Kubernetes). - When both day fields are restricted, a day matching either one runs the job.
- Avoid minute 0 on GitHub Actions, and make Kubernetes Jobs idempotent.