GitHub Actions makes it easy to schedule automated workflows β but it comes with a handful of sharp edges that catch almost every developer at least once. This guide covers everything from basic syntax to the 5 gotchas that explain why your scheduled workflow isn't running when you expect it to.
Scheduled workflows live in your .github/workflows/ directory. The schedule trigger takes a list of cron expressions under the on: key:
# .github/workflows/scheduled-job.yml
name: Scheduled Task
on:
schedule:
# Runs every day at 9:00 AM UTC
- cron: '0 9 * * *'
jobs:
run-task:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run the task
run: echo "Running at $(date)"
The expression must be in single quotes. GitHub Actions YAML is strict about this β unquoted cron expressions with * can cause YAML parse errors on some parsers.
All times are UTC. Use the CronBuilder timezone selector to find your UTC equivalent if you're scheduling for a specific local time.
| Expression | Schedule | UTC time |
|---|---|---|
'0 0 * * *' | Daily at midnight UTC | 00:00 UTC |
'0 9 * * *' | Daily at 9am UTC | 09:00 UTC |
'0 9 * * 1-5' | Weekdays at 9am UTC | MonβFri 09:00 UTC |
'0 6 * * 1' | Every Monday at 6am UTC | Mon 06:00 UTC |
'0 0 1 * *' | 1st of every month, midnight UTC | 1st 00:00 UTC |
'*/5 * * * *' | Every 5 minutes (minimum allowed) | :00 :05 :10... |
'*/15 * * * *' | Every 15 minutes | :00 :15 :30 :45 |
'0 */6 * * *' | Every 6 hours | 00:00 06:00 12:00 18:00 |
'30 8 * * 1-5' | Weekdays at 8:30am UTC (= 9:30 BST) | MonβFri 08:30 UTC |
Unlike Linux cron (which respects the server timezone and supports CRON_TZ), GitHub Actions has no timezone configuration for schedule triggers. The cron: value is always interpreted as UTC.
If you want a job to run at 9am London time (UTC+1 in winter, UTC+2 in summer), you need to write '0 8 * * *' in winter and update it to '0 7 * * *' when DST kicks in β or accept that the local time will drift by an hour twice a year.
steps:
- name: Run only if it's 9am in London
run: |
# Check current London time inside the step
LONDON_HOUR=$(TZ='Europe/London' date +%H)
if [ "$LONDON_HOUR" -eq 9 ]; then
echo "Running at 9am London"
./run-task.sh
fi
This is inelegant but it's the most DST-safe approach if local time matters to you.
GitHub enforces a minimum interval of 5 minutes on scheduled workflows. An expression like * * * * * (every minute) or */2 * * * * (every 2 minutes) will not trigger more frequently β GitHub will simply not run it at the expected cadence, sometimes skipping runs entirely.
# β This will NOT run every minute as expected
- cron: '* * * * *'
# β This will NOT run every 2 minutes as expected
- cron: '*/2 * * * *'
# β
This is the minimum that reliably works
- cron: '*/5 * * * *'
If you need sub-5-minute scheduling, use an external service or trigger the workflow via the GitHub API from your own infrastructure.
GitHub's documentation explicitly states that scheduled workflows may be delayed during periods of high load on GitHub's infrastructure. In practice, delays of 5 to 30 minutes are common at peak hours (especially around :00 and :30 past the hour when everyone's jobs are set to run).
This means GitHub Actions cron is not appropriate for time-sensitive tasks. If your job must fire within 60 seconds of the scheduled time, use a dedicated cron service (Linux cron, AWS EventBridge, Google Cloud Scheduler) and trigger the workflow via the GitHub API.
# Add workflow_dispatch to your workflow file
on:
schedule:
- cron: '0 9 * * 1-5'
workflow_dispatch: # Allows manual + API triggering
# Then trigger it precisely from your own cron server
curl -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/OWNER/REPO/actions/workflows/WORKFLOW_ID/dispatches \
-d '{"ref":"main"}'
If a repository has had no commits, pull requests, or other activity for 60 days, GitHub will automatically disable its scheduled workflows. You'll receive an email notification, but if you miss it, your jobs simply stop running with no other warning.
To re-enable them, go to the Actions tab in your repository and click "Enable" on the disabled workflow.
# Add this step to your scheduled workflow to create a commit
# that keeps the repo active
steps:
- uses: actions/checkout@v4
- name: Keep repo active
run: |
git config user.email "action@github.com"
git config user.name "GitHub Action"
git commit --allow-empty -m "chore: scheduled run $(date -u)"
git push
This is a standard cron behaviour that surprises people in every context, but it's especially painful in GitHub Actions because you can't easily test it. If you specify a non-wildcard value for both day-of-month and day-of-week, the job fires when either condition is true.
# β Intended: "the first Friday of every month"
# β
Actual: "the 1st of every month" OR "every Friday"
- cron: '0 9 1 * 5'
# β
To get "first Friday of the month", check inside the workflow step
on:
schedule:
# Run every Friday at 9am UTC
- cron: '0 9 * * 5'
jobs:
first-friday-only:
runs-on: ubuntu-latest
steps:
- name: Check if first Friday
run: |
# Only proceed if the day of month is 1-7 (first week)
DAY=$(date +%d)
if [ "$DAY" -gt 7 ]; then
echo "Not the first Friday β skipping"
exit 0
fi
echo "First Friday confirmed β running task"
./monthly-task.sh
Waiting for a cron job to fire to see if it works is painful. Here are three ways to test immediately:
on:
schedule:
- cron: '0 9 * * 1-5'
workflow_dispatch: # β add this line
Now you can trigger the workflow manually from the Actions tab while you iterate, then remove workflow_dispatch when you're happy.
# Set to every 5 minutes temporarily to confirm the workflow runs
- cron: '*/5 * * * *'
# Once confirmed, set it to your real schedule
- cron: '0 9 * * 1-5'
Paste your expression into CronBuilder.dev to see the next 10 UTC run times before you push. The expression 0 9 * * 1-5 is easy to confuse with 0 9 * * 1,5 (which runs on Mondays AND Fridays only, not every weekday).
You can attach more than one cron expression to a single workflow. GitHub Actions will trigger the workflow on any matching schedule:
on:
schedule:
# Hourly during UK business hours on weekdays
- cron: '0 8-17 * * 1-5'
# Once overnight for cleanup
- cron: '0 2 * * *'
If you need to behave differently depending on which schedule triggered the run, use the github.event.schedule context:
steps:
- name: Check which schedule triggered this
run: |
if [ "${{ github.event.schedule }}" == "0 2 * * *" ]; then
echo "Running overnight cleanup"
./cleanup.sh
else
echo "Running hourly sync"
./sync.sh
fi
Use CronBuilder.dev to construct your cron expression, preview the next 10 UTC run times, and copy it directly in GitHub Actions format β with the single quotes already included.
Build My Expression βRelated: Cron job not running? β 10 reasons and how to fix. Cron vs systemd timers β when to use each on Linux.