Back to Blog
CI/CD12 min read

GitHub Actions Scheduled Workflows (Cron): Complete Guide

GitHub Actions lets you run workflows on a cron schedule. It is convenient and free for public repositories, but it comes with limitations that catch people off guard: UTC-only scheduling, execution delays, and automatic disabling after 60 days of inactivity. This guide covers everything you need to know to use it effectively.

The Schedule Trigger

To run a workflow on a schedule, use the schedule event in your workflow YAML file. The schedule uses standard cron expression syntax with five fields:

# .github/workflows/scheduled-task.yml
name: Daily Data Sync

on:
  schedule:
    # Run every day at 8:00 AM UTC
    - cron: '0 8 * * *'

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run sync script
        run: python scripts/sync_data.py
        env:
          API_KEY: ${{ secrets.DATA_API_KEY }}

The cron value must be a quoted string (single quotes in YAML). The five fields are identical to standard cron: minute, hour, day of month, month, day of week.

Multiple Schedules

You can define multiple cron schedules for the same workflow:

on:
  schedule:
    # Every 6 hours
    - cron: '0 */6 * * *'
    # Also on the 1st of every month at midnight
    - cron: '0 0 1 * *'

Combining Schedule with Manual Triggers

In practice, you almost always want to combine schedule with workflow_dispatch so you can run the workflow manually during development and debugging:

name: Weekly Report

on:
  schedule:
    - cron: '0 9 * * 1'  # Every Monday at 9 AM UTC
  workflow_dispatch:      # Allow manual trigger from the Actions tab
    inputs:
      force_full_report:
        description: 'Generate full report instead of incremental'
        required: false
        default: 'false'
        type: boolean

jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Generate report
        run: |
          if [ "${{ github.event.inputs.force_full_report }}" = "true" ]; then
            python scripts/report.py --full
          else
            python scripts/report.py
          fi
        env:
          REPORT_EMAIL: ${{ secrets.REPORT_EMAIL }}

With workflow_dispatch, you can click "Run workflow" in the Actions tab of your repository. This is invaluable for testing without waiting for the next scheduled run.

UTC-Only: No Timezone Support

GitHub Actions schedules are always in UTC. There is no timezone configuration, no CRON_TZ variable, and no plans from GitHub to add timezone support.

If you need a workflow to run at 9 AM in a specific timezone, you must manually convert:

Target TimeUTC EquivalentCron Expression
9 AM EST (UTC-5)2 PM UTC0 14 * * *
9 AM CET (UTC+1)8 AM UTC0 8 * * *
9 AM JST (UTC+9)12 AM UTC0 0 * * *

During daylight saving time transitions, your workflow will shift by one hour relative to local time. There is no workaround within GitHub Actions itself. For a deeper look at this problem and solutions, see our guide on cron job timezone issues.

Use the Cron Expression Generator to quickly build and verify your UTC schedule before committing it to your workflow file.

Common Workflow Patterns

Dependency Update Check

name: Check for Dependency Updates

on:
  schedule:
    - cron: '0 6 * * 1'  # Every Monday at 6 AM UTC

jobs:
  check-updates:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm install
      - run: npm outdated || true
      - name: Create issue if updates available
        uses: actions/github-script@v7
        with:
          script: |
            const { execSync } = require('child_process');
            const outdated = execSync('npm outdated --json || true').toString();
            const deps = JSON.parse(outdated || '{}');
            if (Object.keys(deps).length > 0) {
              await github.rest.issues.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title: `Dependency updates available (${Object.keys(deps).length} packages)`,
                body: '```json\n' + JSON.stringify(deps, null, 2) + '\n```',
                labels: ['dependencies']
              });
            }

Stale Issue Cleanup

name: Close Stale Issues

on:
  schedule:
    - cron: '0 0 * * *'  # Daily at midnight UTC

jobs:
  stale:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/stale@v9
        with:
          stale-issue-message: >
            This issue has been automatically marked as stale
            because it has not had recent activity.
          days-before-stale: 60
          days-before-close: 14

Scheduled Security Scan

name: Security Audit

on:
  schedule:
    - cron: '0 4 * * *'  # Daily at 4 AM UTC
  push:
    branches: [main]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm audit --production
      - name: Run Snyk security check
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

Data Pipeline / ETL

name: ETL Pipeline

on:
  schedule:
    - cron: '0 */4 * * *'  # Every 4 hours

jobs:
  etl:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install -r requirements.txt
      - name: Extract
        run: python etl/extract.py
        env:
          SOURCE_DB_URL: ${{ secrets.SOURCE_DB_URL }}
      - name: Transform
        run: python etl/transform.py
      - name: Load
        run: python etl/load.py
        env:
          TARGET_DB_URL: ${{ secrets.TARGET_DB_URL }}
      - name: Notify on failure
        if: failure()
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H 'Content-type: application/json' \
            -d '{"text":"ETL pipeline failed. See: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'

Secrets and Conditional Execution

Secrets work normally in scheduled workflows. Reference them with ${{ secrets.YOUR_SECRET }} as you would in any other workflow.

You can run different logic depending on whether the workflow was triggered by a schedule or manually:

- name: Full sync on schedule, incremental on manual
  run: |
    if [ "${{ github.event_name }}" = "schedule" ]; then
      python sync.py --mode full
    else
      python sync.py --mode incremental
    fi

Known Limitations and Gotchas

GitHub Actions scheduled workflows have several important limitations that are not always obvious from the documentation:

1. Minimum interval: 5 minutes

The shortest schedule you can set is */5 * * * * (every 5 minutes). GitHub will silently ignore schedules more frequent than this. In practice, for public repositories on the free tier, GitHub recommends schedules no more frequent than every 15 minutes.

2. Execution delays are normal

Scheduled workflows do not run at the exact time specified. During periods of high demand on GitHub Actions, delays of 10-30 minutes are common. Delays of over an hour have been reported during peak usage. GitHub does not guarantee execution timing.

3. Auto-disabled after 60 days of inactivity

If a repository has no commits, pull requests, or issues for 60 consecutive days, GitHub automatically disables all scheduled workflows. You will receive an email notification, but if you miss it, your scheduled tasks stop running silently. To prevent this, ensure some activity happens on the repository at least every 60 days.

4. Only runs on the default branch

Scheduled workflows only trigger on the default branch (usually main or master). You cannot schedule workflows on feature branches. Use workflow_dispatch to test on other branches.

5. Free tier minute limits

Free accounts get 2,000 minutes per month for private repositories. Public repositories are unlimited. A workflow that runs every 15 minutes and takes 2 minutes each will consume about 5,760 minutes per month, well over the free tier limit for private repos.

6. No built-in failure alerting

GitHub does not send notifications when a scheduled workflow fails. You must implement alerting yourself using Slack webhooks, email APIs, or third-party services. Without this, failed scheduled workflows can go unnoticed for weeks.

Monitoring Scheduled Runs

Since GitHub does not notify you about scheduled workflow failures, build monitoring into the workflow itself:

jobs:
  task:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run the task
        id: main_task
        run: python scripts/process.py

      # Alert on failure
      - name: Notify Slack on failure
        if: failure()
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H 'Content-type: application/json' \
            -d '{
              "text": "Scheduled workflow failed",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Workflow:* ${{ github.workflow }}\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>"
                  }
                }
              ]
            }'

      # Heartbeat on success (dead man's switch)
      - name: Send heartbeat
        if: success()
        run: curl -s "https://cronjobpro.com/api/heartbeat/${{ secrets.HEARTBEAT_ID }}" > /dev/null

When GitHub Actions Is Not Enough

GitHub Actions scheduled workflows are a reasonable choice for tasks that meet all of these criteria:

  • The task is tightly coupled to the repository (depends on repo code)
  • Execution delays of 10-30 minutes are acceptable
  • The repository will remain active (commits at least every 60 days)
  • UTC-only scheduling is acceptable
  • You do not need automatic retries

If any of those criteria do not fit, consider alternatives:

RequirementBetter Alternative
Exact timing (no delays)CronJobPro, GCP Cloud Scheduler, AWS EventBridge
Timezone-aware schedulingCronJobPro, GCP Cloud Scheduler
Automatic retries on failureCronJobPro, Celery, any HTTP scheduler with retry support
Intervals shorter than 5 minCronJobPro (1-min intervals), self-hosted cron
Inactive/archived repositoryAny external scheduler (will not auto-disable)

For HTTP-based tasks (calling an API endpoint, triggering a webhook, pinging a health check), an external scheduler like CronJobPro is a more reliable choice. It will not auto-disable, it retries on failure, it runs on time, and it sends alerts immediately when something goes wrong.

Key Takeaways

  • Use the schedule event with standard cron syntax in your workflow YAML. Always add workflow_dispatch for manual testing.
  • All schedules are in UTC with no timezone configuration. Convert your target local time manually.
  • Expect 10-30 minute delays during peak usage. Do not use GitHub Actions for time-critical tasks.
  • Scheduled workflows auto-disable after 60 days of repository inactivity. Monitor for this.
  • Build failure alerting into every scheduled workflow. GitHub does not notify you when scheduled runs fail.
  • For precise timing, timezone support, retries, and monitoring, use a dedicated scheduling service instead.

Related Articles

Need reliable scheduled execution?

CronJobPro runs on time, retries on failure, and never auto-disables. Set up timezone-aware HTTP scheduling in under two minutes.