Skip to content
Guide · 7 min read

GitHub Actions cron in production — what nobody tells you on launch day

The 5-minute floor is the easy part. The branch trap, the silent disable, the 60-day kill, and the peak-load drift are the parts that hurt.

Verified by maintainer·Last updated

GitHub Actions cron looks like the easy answer: free, in your repo, no extra infrastructure, no AWS account. It is also the scheduler that most people roll out without reading the docs all the way through. Here is the list of things that have bitten me and the teams I have worked with.

The five-minute floor is real

GitHub silently caps the minimum interval at 5 minutes. */2 * * * * parses, the YAML lints, the workflow page shows a schedule — and it never fires. The runner queue treats sub-5-minute schedules as "no schedule". You will not get an error message. You will get nothing.

The fix is obvious (*/5 or coarser). The trap is that there is no warning anywhere telling you the schedule has been silently dropped. We caught this once after a week of wondering why the nightly report's preview cron never updated.

Schedules run on the default branch only

This is the single biggest gotcha. A new schedule trigger added in a feature branch does not start firing until that branch is merged to main (or whatever your default is). If you want to test a schedule before merge, manually trigger via workflow_dispatch — and add workflow_dispatch to every scheduled workflow anyway, because you will want a manual override the first time something breaks.

on:
  schedule:
    - cron: '0 9 * * 1-5'
  workflow_dispatch:

Peak-load drift is bigger than you think

GitHub Actions schedules are best-effort. During peak load — the top of the hour is the worst — scheduled workflows can be delayed by 10+ minutes or skipped entirely. We have measured delays of 18 minutes on 0 12 * * * jobs. Move to 17 9 * * * (an odd offset) and the same job runs within a minute of its scheduled time, day after day, because almost nobody schedules at :17.

The peak windows we have observed empirically: :00, :30, and:15 past every hour, in descending order. Anything starting with 17, 23, 41 is quiet.

The 60-day inactivity disable

GitHub disables scheduled workflows if a repository has had no commits and no workflow activity for 60 days. The disable is automatic. The re-enable requires a manual click in the Actions UI or a commit. If the repo is a maintenance-mode tool with one cron job and no other activity, plan for this — either keep a tiny "heartbeat" commit going, or accept that the cron will need re-enabling every two months.

UTC, only UTC

Every schedule string is interpreted in UTC. There is no timezone parameter. A schedule of 0 9 * * * fires at 09:00 UTC — which is 11:00 in Berlin in summer, 10:00 in Berlin in winter, 05:00 in New York summer, 04:00 in New York winter. If you want "9 AM local for users in zone X", do the math yourself before writing the cron string and accept that DST will shift your job twice a year.

Concurrent runs and the run_id collision

A scheduled run does not cancel a previous still-running invocation. If your nightly job takes longer than 24 hours one night (transient slow dependency), tomorrow's run starts on top of last night's. The runs are independent — different run_ids, separate logs — but they share whatever your job touches: databases, S3 buckets, the Slack channel you post to.

Use concurrency at the workflow level to serialize:

concurrency:
  group: nightly-report
  cancel-in-progress: false

That puts new runs behind old runs in a queue. Use cancel-in-progress: true if you want "latest wins" instead (useful for refresh-style jobs where the newer fire is more useful than the in-flight older one).

Monitoring — GitHub does not tell you when a run is missed

When a scheduled workflow is delayed or skipped due to peak load, no notification fires. No email, no status page entry. The Actions UI shows the queued run and you can only notice the gap by looking at the run history.

Build a deadman switch into the job itself. The simplest pattern: after the real work, curl a heartbeat URL. Configure the heartbeat service to page you if it doesn't hear from the job within an hour of the expected time.

# At the very end of the cron workflow, after every step has succeeded:
- name: Heartbeat
  run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/$\{{ secrets.HEALTHCHECK_UUID }}"

Free options that work: Healthchecks.io, Better Stack heartbeats, Sentry cron monitors, or roll your own with a Cloudflare Worker. Whichever you pick, the cost of not having one is a missed business deadline you find out about from a customer.

The minute-2 / minute-3 minimum is a myth — but cost isn't

The Actions cron docs no longer mention the historical 1- or 2-minute minimum; the 5-min floor is what they enforce now. But invocation cost still scales. On a public repo everything is free. On a private repo with the included minutes exhausted, a */5 schedule firing 288 times a day, 30 days a month, is 8,640 jobs per month at whatever your per-minute billing rate is. If each job runs 30 seconds, that is 4,320 billed minutes per month for one cron alone. Watch the bill.

Secrets, environments, and the access surprise

Repository secrets are available to scheduled workflows by default. Environment secrets are only available if the job is bound to that environment (environment: productionin the job). A common bug: developer moves a database URL from repo-level to environment-level for safety, forgets to bind the cron job to the environment, and the next scheduled fire fails with a cryptic connection error because the env var is empty.

Read next

all guides →
Spotted something wrong? Use the contact form.