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.

Cron expression fields and two schedulers Diagram breaking a cron expression into its five fields, then showing two consumers: a server crontab and a GitHub Actions schedule trigger, both interpreting the expression in UTC. 0 2 * * * minute (0) hour (2 UTC) day / month day-of-week (*) server crontab local clock · no retry runs in server TZ Actions schedule always UTC · may delay uploads artifacts

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 cron and 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.

Bash
# 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.

YAML
# .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.

YAML
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:

Python
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.

YAML
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.

Python
# 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: true on a report matrix. The default cancels every region when one fails, so a single bad dataset loses the whole night’s output. Set fail-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>&1 so 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:

Bash
# 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.