Most GitHub Actions tutorials cover push and pull request triggers. The schedule trigger gets less attention, but it is often where the most valuable automation lives. A nightly dependency audit that opens an issue when it finds CVEs. A weekly cost check that alerts Slack when your AWS bill spikes. A certificate check that pages you before the cert expires rather than after. None of these need a developer to press a button.
Cron Syntax Quick Reference#
GitHub Actions uses standard POSIX cron syntax with five fields.
| Field | Values | Example |
|---|---|---|
| Minute | 0-59 | 30 = at :30 |
| Hour | 0-23 | 9 = 09:00 |
| Day of month | 1-31 | 1 = first of month |
| Month | 1-12 or JAN-DEC | * = every month |
| Day of week | 0-6 or SUN-SAT (0=Sunday) | 1 = Monday |
# Format: minute hour day-of-month month day-of-week
#
# Examples:
# 0 9 * * 1-5 Every weekday at 09:00
# 0 0 * * * Every day at midnight
# 30 6 * * 1 Every Monday at 06:30
# 0 */4 * * * Every 4 hours
# 0 9 1 * * First of every month at 09:00GitHub Actions runs all scheduled workflows in UTC. There is no way to configure a timezone for the schedule trigger. If your team is in CET (UTC+1) and you want a 09:00 daily run, use 0 8 * * *. During CEST (UTC+2) that becomes 0 7 * * *. Adjust your cron expression seasonally or accept the one-hour drift.
GitHub does not guarantee exact execution times for scheduled workflows. During periods of high GitHub Actions load, runs may be delayed by up to several minutes. Do not use scheduled workflows for anything requiring sub-minute precision.
Combining schedule with workflow_dispatch#
Always add workflow_dispatch alongside schedule. Without it, you cannot manually trigger the workflow for testing, and you are stuck waiting for the next scheduled run every time you make a change.
name: Nightly Audit
on:
schedule:
- cron: '0 2 * * *' # 02:00 UTC every day
workflow_dispatch: # also triggerable manually from the UI or API
inputs:
dry_run:
description: 'Run in dry-run mode (do not open issues)'
type: boolean
default: falseworkflow_dispatch with inputs lets you parameterize manual runs without creating a separate debug workflow.
Use Case 1: Nightly Dependency Vulnerability Audit#
Run a vulnerability scanner every night and open a GitHub issue if anything is found, rather than finding out when a developer happens to run npm audit locally.
name: Nightly Dependency Audit
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Run pip-audit
id: audit
run: |
pip install pip-audit
pip-audit --output json > audit-results.json || echo "VULNERABILITIES_FOUND=true" >> $GITHUB_ENV
- name: Open issue if vulnerabilities found
if: env.VULNERABILITIES_FOUND == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = fs.readFileSync('audit-results.json', 'utf8');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[Security] Dependency vulnerabilities found - ${new Date().toISOString().split('T')[0]}`,
body: `## Vulnerability Audit Results\n\`\`\`json\n${results}\n\`\`\`\n\nTriggered by nightly audit workflow.`,
labels: ['security', 'dependencies']
});Use Case 2: Weekly AWS Cost Check#
Query your AWS cost for the past 7 days every Monday morning and post to Slack if spend exceeds a threshold.
name: Weekly AWS Cost Check
on:
schedule:
- cron: '0 7 * * 1' # Monday 07:00 UTC
workflow_dispatch:
jobs:
cost-check:
runs-on: ubuntu-latest
steps:
- name: Get AWS cost for last 7 days
id: cost
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: eu-central-1
run: |
END=$(date -u +%Y-%m-%d)
START=$(date -u -d '7 days ago' +%Y-%m-%d)
AMOUNT=$(aws ce get-cost-and-usage \
--time-period Start=$START,End=$END \
--granularity MONTHLY \
--metrics "UnblendedCost" \
--query 'ResultsByTime[0].Total.UnblendedCost.Amount' \
--output text)
echo "COST=$AMOUNT" >> $GITHUB_ENV
echo "Weekly spend: $AMOUNT USD"
- name: Alert Slack if spend exceeds threshold
if: ${{ env.COST > 500 }}
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
curl -s -X POST "$SLACK_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{\"text\": \":warning: AWS weekly spend is \$$COST USD (threshold: \$500). Review the Cost Explorer.\"}"Use Case 3: SSL Certificate Expiry Check#
Check your public endpoints’ certificate expiry dates weekly and alert when a certificate is within 30 days of expiring.
name: Certificate Expiry Check
on:
schedule:
- cron: '0 8 * * 1' # Monday 08:00 UTC
workflow_dispatch:
jobs:
cert-check:
runs-on: ubuntu-latest
strategy:
matrix:
host:
- api.example.com
- app.example.com
steps:
- name: Check certificate for ${{ matrix.host }}
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
EXPIRY=$(echo | openssl s_client -servername ${{ matrix.host }} \
-connect ${{ matrix.host }}:443 2>/dev/null \
| openssl x509 -noout -enddate \
| cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
echo "Certificate for ${{ matrix.host }} expires in $DAYS_LEFT days ($EXPIRY)"
if [ "$DAYS_LEFT" -lt 30 ]; then
curl -s -X POST "$SLACK_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{\"text\": \":rotating_light: Certificate for *${{ matrix.host }}* expires in *$DAYS_LEFT days* ($EXPIRY). Renew immediately.\"}"
fiUse strategy.matrix to check multiple hosts in parallel without duplicating job definitions. Each matrix entry runs in its own job with its own log.
Use Case 4: Automated Dependency Updates#
Run npm update weekly, commit the result, and open a pull request automatically if any packages were updated.
name: Weekly Dependency Update
on:
schedule:
- cron: '0 6 * * 1' # Monday 06:00 UTC
workflow_dispatch:
jobs:
update:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Update dependencies
run: npm update
- name: Create pull request if anything changed
uses: peter-evans/create-pull-request@v6
with:
commit-message: 'chore: weekly dependency update'
title: 'chore: weekly dependency update'
body: 'Automated weekly dependency update via GitHub Actions.'
branch: automated/weekly-deps-update
delete-branch: true
labels: dependenciesConcurrency Control#
If a scheduled workflow takes longer than its schedule interval (e.g., a 5-minute workflow on a 2-minute schedule), multiple runs pile up. Use the concurrency key to cancel in-progress runs when a new one starts.
name: Nightly Audit
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
concurrency:
group: nightly-audit
cancel-in-progress: true
jobs:
audit:
# ...group is a string that identifies the concurrency group. Any new run in the same group will cancel the currently running one (if cancel-in-progress: true). Use a fixed string for scheduled workflows where you never want two runs overlapping.
Debugging Scheduled Workflows#
Scheduled workflows only run on the default branch. If you add a schedule trigger on a feature branch, it will be ignored. Test your workflow by merging to the default branch and using workflow_dispatch to trigger it manually before the next scheduled run.
Useful debugging checklist:
- Add
workflow_dispatchto every scheduled workflow (no exceptions). - Check the Actions tab to see if the workflow is listed – if it is not listed, the YAML is invalid or the workflow is on a non-default branch.
- Use
act(the local GitHub Actions runner) for rapid iteration without waiting for the scheduler. - Add a step that prints
date -uto confirm the UTC time context.
If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.