cronuru
Guide

GitHub Actions Scheduled Workflows — A Complete Guide

GitHub Actions' `on.schedule.cron` trigger is the easiest way to run a workflow on a recurring schedule, but it has two real constraints — a 5-minute minimum interval, and silent disabling of public-repository schedules after 60 days of inactivity — that catch every engineer at least once. Schedules default to UTC, though since March 2026 an optional per-schedule `timezone:` field lets you pin a local zone. Every spec claim on this page is quoted from GitHub's own documentation and linked to its source, verified 2026-07-24.

Updated

The spec, precisely

Every row here is what GitHub’s own documentation states, with the primary source linked. Where GitHub is silent, this table says so rather than guessing — the gaps matter as much as the rules.

PropertyWhat GitHub documents
SyntaxPOSIX cron syntax, five fields
Fieldsminute hour day-of-month month day-of-week
Seconds fieldNone. Not part of the 5-field format
OperatorsExactly four: * (any value), , (list separator), - (range), / (step)
NicknamesNot supported@yearly, @monthly, @weekly, @daily, @hourly, @reboot
L, W, #, ?Not documented, not supported
Minimum interval5 minutes
Default timezoneUTC
timezone: keyOptional IANA timezone string, per schedule entry (since March 2026)
DST spring-forwardSchedules in skipped hours advance to the next valid time
DST fall-backNot documented — see below
BranchRuns on the latest commit on the default branch only
Inactivity disablePublic repositories, after 60 days with no repository activity
Timing guaranteeNone. May be delayed under load, and queued jobs may be dropped

Four of these are worth the exact wording, because they’re the ones most often stated wrong elsewhere.

On the minimum interval — the docs say simply:

The shortest interval you can run scheduled workflows is once every 5 minutes.

Note what this does not say: there’s no documented statement that a shorter expression is rejected, errors, or is coerced. * * * * * is valid POSIX cron and a workflow file containing it validates and commits cleanly. It just won’t run every minute. Treat 5 minutes as the floor and don’t write an interval you won’t get.

On nicknames, the docs are unusually explicit:

GitHub Actions does not support the non-standard syntax @yearly, @monthly, @weekly, @daily, @hourly, and @reboot.

So @daily is not a shorter way to write 0 0 * * * here — it’s a schedule that never fires.

On DST, this is the sentence people are looking for:

For schedules that set timezone to a time zone that observes daylight saving time (DST), during DST spring-forward transitions, scheduled workflows in skipped hours advance to the next valid time. For example, a 2:30 AM schedule advances to 3:00 AM.

Spring-forward is therefore defined behaviour. Fall-back is not. GitHub documents nothing about what happens when the local hour repeats, so a schedule inside that hour has no documented guarantee of running once rather than twice. If a job is not idempotent and lands between 1 and 3 AM local, either move it outside the transition window or omit timezone: and schedule it in UTC.

On the inactivity rule, the qualifier is load-bearing:

In a public repository, scheduled workflows are automatically disabled when no repository activity has occurred in 60 days.

This applies to public repositories. Widely repeated as a blanket rule for every repository, it isn’t one — private repositories are not covered by that statement.

Primary sources: Events that trigger workflows — schedule, Workflow syntax — on.schedule, and Disabling and enabling a workflow. Verified 2026-07-24.

The schedule trigger

The minimal scheduled workflow:

name: nightly-report
on:
  schedule:
    - cron: '0 0 * * *'
jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/generate-report.sh

The schedule trigger accepts an array of cron entries, each one a standard 5-field Unix cron expression. See the Unix cron dialect reference for the full syntax — fields are minute, hour, day-of-month, month, and day-of-week.

GitHub Actions doesn’t support the @hourly / @daily / @weekly / @monthly / @yearly shortcuts. Write the explicit expression every time:

What you wantWrite
Hourly0 * * * *
Daily at midnight0 0 * * *
Weekly on Sunday0 0 * * 0
First of month0 0 1 * *

@reboot isn’t supported either (there’s no notion of a “system boot” in a CI runner).

5-minute minimum interval

* * * * * does not work on GitHub Actions. The platform enforces a 5-minute minimum schedule interval. Cron expressions that would fire more often than every 5 minutes are technically accepted by the YAML parser, but GitHub will simply not trigger the workflow at those times.

The fastest valid schedule is */5 * * * * (every 5 minutes). If you need sub-5-minute precision, GitHub Actions is the wrong tool:

  • Cloudflare Workers cron triggers go down to 1 minute
  • AWS EventBridge Scheduler goes down to 1 minute
  • A self-hosted runner with a local cron daemon (every minute, calls back into GitHub)
  • A long-running service (Heroku, Render, Fly) with an in-process scheduler

Timezones (UTC default + timezone field)

Cron expressions in on.schedule default to UTC. Since March 2026, you can pin a specific zone by adding an optional timezone: field — an IANA name — as a sibling of cron: in the same list item:

on:
  schedule:
    - cron: '0 9 * * 1-5'
      timezone: 'America/Los_Angeles'   # runs 9 AM Pacific year-round

GitHub handles daylight saving on the spring-forward side, and documents it precisely: “during DST spring-forward transitions, scheduled workflows in skipped hours advance to the next valid time. For example, a 2:30 AM schedule advances to 3:00 AM.” So “9 AM Pacific” stays 9 AM through both PST and PDT with a single entry.

Fall-back is not documented. GitHub says nothing about the autumn transition, when the local hour repeats — so a schedule landing inside the repeated hour has no documented guarantee that it fires once rather than twice. If the job isn’t idempotent and sits between 1 and 3 AM local, either move it outside that window or drop timezone: and schedule it in UTC, where the ambiguity doesn’t exist.

Before this feature GitHub Actions was UTC-only, and the standard workaround was two cron entries (one for PST, one for PDT) gated with a date-range check on github.event.schedule. If you see that pattern in older workflows or blog posts, it’s now obsolete — replace it with a single cron: + timezone: pair.

If you don’t need local-time alignment, omit timezone: and everything runs in UTC as before.

Multiple schedules

A workflow can have multiple cron entries:

on:
  schedule:
    - cron: '0 9 * * 1-5'   # Weekdays at 9 AM UTC
    - cron: '0 0 * * *'     # Every day at midnight UTC

Inside the workflow, github.event.schedule tells you which one fired:

jobs:
  dispatch:
    runs-on: ubuntu-latest
    steps:
      - name: Morning standup
        if: github.event.schedule == '0 9 * * 1-5'
        run: ./scripts/standup.sh

      - name: Nightly cleanup
        if: github.event.schedule == '0 0 * * *'
        run: ./scripts/cleanup.sh

This pattern lets you run different work on different schedules from a single workflow file. Useful for: morning + evening reports, weekday + weekend operations, different timezone variants of the same job.

Inactive repositories

In a public repository, GitHub disables scheduled workflows when no repository activity has occurred in 60 days. The disabling is silent — no email, no PR comment, no Actions log entry. Your workflow just stops running.

The public repository qualifier is part of the documented rule and is routinely dropped when this gets repeated. GitHub’s wording is “In a public repository, scheduled workflows are automatically disabled when no repository activity has occurred in 60 days” — private repositories are not covered by that statement. If you’re on a private repo, this is not the reason your schedule stopped; look at reliability and delays or a disabled workflow instead.

“Activity” means a push, a release, a PR merge — anything that modifies the repository. Issue comments and stars don’t count.

To prevent this:

  1. Have actual development activity. Repositories that ship regularly never hit the limit.
  2. Add a no-op keepalive workflow that pushes a small commit every 50 days (e.g., updating a last-active.txt file or bumping a version tag).
  3. Use a different scheduler. EventBridge Scheduler, Cloudflare Workers cron, or Vercel cron don’t have this restriction. Have them dispatch the GitHub workflow via gh workflow run or the REST API.

You can re-enable a disabled workflow via the Actions tab UI or with gh workflow enable <workflow-name> from the CLI.

Reliability and delays

GitHub’s own docs explicitly warn:

Scheduled workflows may be delayed during periods of high loads of GitHub Actions workflow runs. High load times include the start of every hour.

In practice this means:

  • At the top of every hour, scheduled workflows are commonly delayed by 5–30 minutes.
  • At :30 of every hour, delays are typically much shorter.
  • During GitHub-wide incidents, workflows can be delayed by hours or skipped entirely.

If precise timing matters (an audit, a regulatory report, a downstream system with a tight SLA), don’t use GitHub Actions cron. Use a more reliable scheduler — EventBridge Scheduler, Cloudflare Workers cron, or a self-hosted runner with local cron — and have it dispatch the GitHub workflow.

A trick to reduce average delay: schedule at minute 7, 13, 23, etc. instead of 0 or 30. The platform processes a wave of scheduled work at predictable times and off-peak minutes get serviced faster.

Pair with workflow_dispatch

Every scheduled workflow should also be manually triggerable. Add workflow_dispatch: to the on: block:

on:
  schedule:
    - cron: '0 0 * * *'
  workflow_dispatch:

This adds a “Run workflow” button in the Actions tab. Use it to:

  • Test the workflow without waiting for the next scheduled fire
  • Re-run after a transient failure
  • Trigger an emergency execution outside the schedule

You can also trigger it programmatically:

gh workflow run nightly-report.yml

Or via the REST API (good for paired schedulers like EventBridge dispatching into GitHub).

For workflows that take parameters when manually triggered:

on:
  schedule:
    - cron: '0 0 * * *'
  workflow_dispatch:
    inputs:
      dry-run:
        description: 'Run without making changes'
        type: boolean
        default: false

jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - run: |
          if [ "${{ github.event.inputs.dry-run }}" = "true" ]; then
            echo "Dry run mode"
          else
            ./scripts/generate-report.sh
          fi

Debugging

When a scheduled workflow isn’t behaving:

  • Confirm the workflow is on the default branch. GitHub’s docs are unambiguous: “Scheduled workflows run on the latest commit on the default branch.” A schedule: trigger sitting on a feature branch will never fire, no matter how correct the cron is — and because the workflow file is otherwise valid, nothing warns you. This is the single most common cause of “my new scheduled workflow does nothing,” since the natural way to add one is on a branch behind a PR. It only starts running once merged.
  • Verify the cron expression parses. Paste it into the parser with the GitHub Actions dialect selected. Check the next-run times match your intent (and remember they’ll be in UTC unless you set timezone:).
  • Check the Actions tab. Filter by event type “schedule” to see only scheduled runs. Compare run timestamps with the schedule to spot delays.
  • Check repository inactivity. Go to Actions tab → if the workflow is grayed out or marked as disabled, that’s the inactive-repo trap.
  • Add a smoke step at the start. A first step that just echo "Schedule: ${{ github.event.schedule }} at $(date -u)" makes it easy to see in logs which schedule fired and when.
  • Manually trigger via workflow_dispatch to confirm the workflow itself works. If it runs manually but not on schedule, the schedule is the issue (delay, disabled, wrong cron).

For workflows that absolutely must run on time, set up a separate canary alert: a different system pings a webhook every time the scheduled workflow completes successfully, and alerts if 90+ minutes elapse without one. GitHub Actions’ own status page is too coarse to catch per-workflow degradation.

Cron on other platforms

Running scheduled work on a different platform? Each handles cron a little differently:

Frequently asked questions

Can I run a GitHub Actions workflow every minute?
No. GitHub Actions enforces a minimum schedule interval of 5 minutes. `* * * * *` is technically valid syntax but will not trigger — the fastest valid expression is `*/5 * * * *`.
Why didn't my scheduled workflow run on time?
GitHub explicitly warns that scheduled workflows can be delayed during periods of high Actions load. Delays of 5–30 minutes are common; longer delays happen during peak usage. There's no SLA on scheduled run timing.
Why did my scheduled workflow stop running?
If it is a **public** repository, the likeliest cause is 60 days without activity — GitHub's documented rule is "In a public repository, scheduled workflows are automatically disabled when no repository activity has occurred in 60 days," and it happens silently. Push any commit and re-enable via the Actions tab or `gh workflow enable`. The *public repository* qualifier matters: GitHub does not document this rule for private repos, so on a private repository check load-related delays, a manually disabled workflow, or a schedule that is not on the default branch instead.
How do I schedule a workflow in my local timezone?
Since March 2026 you can add a `timezone:` field (an IANA name) next to the cron entry, and GitHub adjusts for daylight saving automatically: `- cron: '0 9 * * 1-5'` with `timezone: 'America/Los_Angeles'` on the following line runs at 9 AM Pacific year-round. Before this feature GitHub Actions was UTC-only and you had to offset the hour manually with two cron entries — that workaround is no longer needed.
Can I trigger a scheduled workflow manually for testing?
Yes — add `workflow_dispatch:` alongside `schedule:` and the workflow gets a 'Run workflow' button in the Actions tab. Pair this with every scheduled workflow.