Scheduling Nightly PDF Runs with Cron and GitHub Actions
An automated spatial report is only useful if it lands on schedule without anyone pressing a button. Both a self-hosted crontab entry and a GitHub Actions schedule: trigger can drive a nightly render, and they fail in different ways: cron runs on your server’s clock and never retries; Actions runs in UTC on shared infrastructure that may delay or drop a run under load. This guide, part of the Scheduled Batch Report Generation workflow, walks the cron field-by-field, sets up an Actions workflow, defuses the UTC pitfall that ships reports an hour early, and adds a catch-up guard for missed runs.
Prerequisites
- A render command that exits non-zero on failure (a Python entrypoint or the Docker image from Installing WeasyPrint Dependencies in Slim Docker Images)
- For cron: a Linux host with
cronand permission to edit a user crontab - For Actions: a repository with Actions enabled and any credentials stored as encrypted secrets
- Python 3.10+ if you use the catch-up guard script below
Step 1: Write the Cron Expression
A cron expression is five space-separated fields: minute, hour, day-of-month, month, day-of-week. For a nightly run at 02:00 the expression is 0 2 * * *. The * means “every value,” so the day and month fields leave the run unrestricted.
# minute hour day-of-month month day-of-week command
# 0 2 * * * -> every day at 02:00
0 2 * * * /usr/local/bin/render-reports.sh >> /var/log/reports.log 2>&1
Two field details bite people: day-of-week accepts 0 and 7 for Sunday, and if you set both day-of-month and day-of-week to non-*, cron runs when either matches (a logical OR), not both. For a plain nightly job keep them at *.
Step 2: Add a schedule Trigger to a Workflow
GitHub Actions uses the same five-field cron in an on.schedule block. The workflow checks out the repo, sets up Python, renders, and the runner is discarded afterward.
# .github/workflows/nightly-reports.yml
name: nightly-reports
on:
schedule:
- cron: "0 2 * * *" # 02:00 UTC — see Step 3 about timezones
workflow_dispatch: {} # manual trigger for testing
jobs:
render:
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
- run: python -m reports.render --date "$(date -u +%F)"
workflow_dispatch is essential: scheduled workflows cannot be tested by waiting until 02:00, so the manual trigger lets you run the exact same job on demand.
Step 3: Handle the UTC Timezone
This is the pitfall that ships reports at the wrong time. Both crontab-on-a-UTC-host and GitHub Actions interpret the schedule in UTC; Actions offers no timezone option at all. If your “nightly” means 02:00 in America/New_York (UTC−5 in winter, UTC−4 in summer), you must convert — and daylight saving means the correct UTC hour shifts twice a year.
on:
schedule:
# 02:00 America/New_York during EST (winter) = 07:00 UTC
- cron: "0 7 * * *"
# 02:00 America/New_York during EDT (summer) = 06:00 UTC
- cron: "0 6 * * *"
Listing both entries and guarding the job with a timezone check inside the script is the robust pattern. In the render script, resolve the local wall-clock time explicitly:
from datetime import datetime
from zoneinfo import ZoneInfo
local_now = datetime.now(ZoneInfo("America/New_York"))
if local_now.hour != 2:
raise SystemExit(0) # wrong DST branch fired — skip cleanly
Step 4: Fan Out With a Matrix
One schedule can render many report sets in parallel jobs using a matrix. Each matrix leg gets its own runner, so a slow region does not block the others.
jobs:
render:
runs-on: ubuntu-latest
strategy:
fail-fast: false # one region failing must not cancel the rest
matrix:
region: [north, south, coastal, interior]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt
- run: python -m reports.render --region ${{ matrix.region }}
- uses: actions/upload-artifact@v4
with:
name: report-${{ matrix.region }}
path: output/*.pdf
retention-days: 14
fail-fast: false is the load-bearing setting: without it, the first failing region cancels every sibling job. When a single machine’s memory cannot hold many concurrent renders, move the fan-out inside one job with Parallel Batch Rendering with ProcessPoolExecutor instead of a runner-per-region matrix.
Step 5: Upload Artifacts and Catch Missed Runs
Scheduled Actions are best-effort — GitHub may delay or drop a run when the queue is congested, and disabled repositories stop scheduling after 60 days of inactivity. Add a catch-up guard that renders any date the artifact store is missing, so a skipped night self-heals on the next run.
# reports/catchup.py
from __future__ import annotations
from datetime import date, timedelta
from pathlib import Path
def missing_dates(output_dir: Path, back_days: int = 3) -> list[date]:
"""Return recent dates with no rendered PDF, newest gaps first."""
today = date.today()
gaps: list[date] = []
for delta in range(1, back_days + 1):
d = today - timedelta(days=delta)
if not (output_dir / f"report-{d.isoformat()}.pdf").exists():
gaps.append(d)
return gaps
Once the artifacts are versioned, push them to durable storage rather than leaving them in Actions’ 14-day retention — see Versioning Generated PDFs in S3 Object Storage for immutable long-term storage.
Key Parameters / Configuration Reference
| Field / Setting | Where | Example | Notes |
|---|---|---|---|
| cron field order | both | min hr dom mon dow |
Five fields; DOM+DOW is an OR |
on.schedule.cron |
Actions | "0 6 * * *" |
Always UTC; no TZ option |
workflow_dispatch |
Actions | {} |
Manual trigger to test schedules |
fail-fast |
matrix | false |
Keep siblings running on failure |
retention-days |
upload-artifact | 14 |
Max Actions artifact lifetime |
ZoneInfo(...) |
render script | "America/New_York" |
Resolve DST-correct local hour |
Common Pitfalls
- Assuming the schedule runs in local time. Actions is UTC-only; a UTC-configured server crontab is too. A
0 2 * * *meant for New York fires at 21:00 or 22:00 local. Convert to UTC and account for DST. fail-fast: trueon a report matrix. The default cancels every region when one fails, so a single bad dataset loses the whole night’s output. Setfail-fast: false.- Relying on Actions never missing a run. Scheduled workflows are best-effort and pause after 60 days of repo inactivity. Add the catch-up guard and monitor for gaps.
- No output redirection in crontab. Cron mails output nowhere useful by default; a crash leaves no trace. Always append
>> logfile 2>&1so failures are diagnosable.
Verification
Test the schedule without waiting for 02:00 by triggering the workflow manually and asserting the artifact appears, then dry-run the catch-up logic:
# Trigger the scheduled workflow on demand and watch it
gh workflow run nightly-reports.yml
gh run watch --exit-status
# Confirm the catch-up guard reports no gaps once artifacts exist
python -c "from pathlib import Path; from reports.catchup import missing_dates; \
print('missing:', missing_dates(Path('output')))"
A green gh run watch --exit-status plus an empty missing: list confirms both the trigger and the self-healing guard work before you trust the unattended nightly window.
Related
- Scheduled Batch Report Generation — parent section covering the full batch-generation workflow
- Parallel Batch Rendering with ProcessPoolExecutor — the sibling guide for fanning renders across CPU cores within one job
- Versioning Generated PDFs in S3 Object Storage — where the nightly artifacts should land for durable storage
- Installing WeasyPrint Dependencies in Slim Docker Images — the container the scheduled job pulls to render