GitHub Actions is the most widely used free compute for running Python scripts on a schedule — mostly because it's already in every developer's workflow and the free minutes are generous. But it comes with real gotchas that catch developers off guard in production.
This guide is a practical walkthrough of everything you need to know:
- How to write a proper
scheduleworkflow in YAML - Running Python scripts with dependencies
- Passing secrets into your scheduled job
- Free tier limits that will eventually bite you
- When GitHub Actions is the wrong tool for the job
A Minimal GitHub Actions Cron YAML
Create .github/workflows/daily-job.yml in your repository:
name: Daily Python Job
on:
schedule:
# Runs at 08:00 UTC every day
- cron: "0 8 * * *"
workflow_dispatch: # Also allows manual trigger from GitHub UI
jobs:
run-script:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run job
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
run: python jobs/daily_report.py
The workflow_dispatch trigger is crucial — it lets you test your workflow manually without waiting for the schedule to fire.
Your Python Script
The script doesn't need any special structure. It's just regular Python:
# jobs/daily_report.py
import os
import requests
from datetime import date
def run():
api_key = os.environ["API_KEY"]
db_url = os.environ["DATABASE_URL"]
print(f"Starting daily report — {date.today()}")
# Example: pull data and push summary to Slack
response = requests.get(
"https://api.yourservice.com/metrics",
headers={"X-API-Key": api_key}
)
response.raise_for_status()
metrics = response.json()
# Push to Slack
slack_webhook = os.environ.get("SLACK_WEBHOOK_URL")
if slack_webhook:
requests.post(slack_webhook, json={
"text": f"Daily metrics: {metrics['total_users']} users, {metrics['revenue']} revenue"
})
print("Done.")
return metrics
if __name__ == "__main__":
run()
Passing Secrets
Never hardcode API keys. In GitHub Actions:
- Go to Settings → Secrets and variables → Actions
- Click New repository secret
- Add each key (
DATABASE_URL,API_KEY, etc.)
Access them in your YAML via ${{ secrets.SECRET_NAME }} and in Python via os.environ["SECRET_NAME"].
Handling Dependencies with Caching
For heavy dependencies (like pandas, playwright, torch), caching speeds up your runs dramatically:
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: pip install -r requirements.txt
Without caching, a pandas + scikit-learn install takes 60–90 seconds on every run.
The Real Limitations of GitHub Actions for Cron Jobs
1. 5-minute minimum interval in practice
The GitHub Actions scheduler uses cron syntax, so technically * * * * * would fire every minute. But in practice, GitHub queues scheduled workflows and may delay them by up to 15 minutes during high-load periods. For anything requiring precision timing, this is a dealbreaker.
2. Scheduled workflows are disabled after 60 days of inactivity
If no one pushes to your repo for 60 days, GitHub automatically disables your scheduled workflow. You'll miss runs without any notification. This is a documented GitHub policy and has caught many developers off guard.
3. Public repo free minutes vs. Private repo limits
- Public repos: Unlimited free minutes for scheduled workflows
- Private repos: 2,000 minutes/month on the free plan, 3,000 on Pro
A 15-minute job running every hour = 24 runs × 15 minutes = 360 minutes/day = 10,800 minutes/month. That's 5× your private repo limit.
4. No per-run execution history in a searchable format
Workflow run logs are kept for 90 days. Searching across runs for a specific log line requires the GitHub CLI or API — there's no built-in "show me all runs where this print() appeared" dashboard.
5. No retry logic
If your script fails (non-zero exit code), the run is marked as failed. GitHub does not retry. You implement retries yourself, or you miss the execution entirely.
When GitHub Actions is the Right Choice
GitHub Actions works beautifully for scheduled tasks when:
- Your job runs once a day or less (the timing imprecision doesn't matter)
- Your repo is public (unlimited free minutes)
- The job is part of your CI/CD pipeline — like a nightly test suite or a data backfill that's naturally tied to your codebase
- You need matrix jobs across multiple OS or Python versions
When to Switch to a Dedicated Cron Service
Switch away from GitHub Actions when:
| Situation | Why GitHub Actions Falls Short |
|---|---|
| Sub-hourly schedules | Up to 15-minute delay from the scheduler |
| Private repo, many jobs | Free minute quota exhausted quickly |
| Long-running scraper (>10 min) | Job timeout + minutes consumed quickly |
| Production critical (payment, reports) | No guaranteed timing, no built-in retry |
| Standalone script, not tied to a codebase | Heavyweight overhead for a simple cron |
Migrating a GitHub Actions Cron to LiteLambda
If you hit any of the limits above, here's how to migrate your existing workflow:
- Take the Python script you call in the
run:step - Wrap your
run()function asdef handler(event, context): - Paste the code into LiteLambda's editor
- Move your GitHub secrets to LiteLambda's Environment Variables section
- Copy the cron expression from your YAML into the Schedule field
# Before (GitHub Actions script)
def run():
# your code here
pass
# After (LiteLambda handler)
def handler(event, context):
# same code here
return {"status": "success"}
LiteLambda runs on a dedicated scheduler with guaranteed execution times, built-in retry logic, and a per-run log dashboard — without counting against any GitHub minute quota.