mailwal

Stop Paying for Cron: Running Scheduled Jobs on GitHub Actions

You don’t need a VPS to run a script once an hour. You probably already have a scheduler, a clean Linux machine, and a secrets store sitting in your GitHub account.

By Nuno Bispo • 2 Nov 2026

You don’t need a VPS to run a script once an hour. You probably already have a scheduler, a clean Linux machine, and a secrets store sitting in your GitHub account.

A lot of side projects need the same small thing: run a script on a schedule. Pull some data from an API every hour. Render a chart every morning. Ping a service every five minutes. The usual answer is a $5 VPS with a crontab, and that works, until you have to patch it, monitor it, and remember what you installed on it two years ago.

GitHub Actions can do the same job with no server to maintain. A scheduled workflow gives you a fresh Ubuntu machine on a cron schedule, with Python preinstalled, encrypted secrets, logs, and failure notifications. For public repositories it is free, and for private ones the free allowance goes further than you might expect.

In this article, you’ll build two real examples:

  1. A data collection job that fetches readings from an API and stores them in a cloud Postgres database.
  2. A dashboard renderer that draws a chart as a 1-bit image sized for an e-ink screen, which a small ESP32 device downloads whenever it wakes up.

What a scheduled workflow looks like

A workflow is a YAML file in .github/workflows/.

If Actions YAML is new to you, start with GitHub Actions for Python Projects for the vocabulary (workflow, job, step, runner), then come back here for the schedule trigger.

That trigger takes a standard cron expression:

on:
  schedule:
    - cron: '7 * * * *'   # minute 7 of every hour
  workflow_dispatch:       # also allow manual runs

Two details before you paste anything:

  • Times are in UTC. There is no timezone setting, so convert in your head or in a comment.
  • The shortest interval is every 5 minutes, and runs are not exact. During busy periods GitHub delays scheduled jobs, sometimes by several minutes. That is why the example above uses minute 7 rather than minute 0: the top of the hour is when everyone else’s jobs fire too.

Adding workflow_dispatch gives you a Run workflow button, which is invaluable while you are testing. Waiting an hour to see whether your cron expression works is nobody’s idea of fun. The quieter failure modes: schedules that never start, and public repos that go dormant, can wait until you have a green run.

Example 1: Collect data into a cloud database

The first job collects a weather reading every hour and stores it in Postgres.

Any hosted Postgres works, but for this project I used Neon. The free tier gives you a real Postgres database with scale-to-zero when idle, which fits an hourly job that only needs the database for a few seconds.

Create a project, pick a region close to you, and leave Auth off unless you need it:

Neon - Create Project

Open Connect, leave connection pooling on, and copy the connection string. The pooled URL is what you want for short-lived runners like GitHub Actions:

Neon - Database URL

The data source is Open-Meteo, which needs no API key:

Open Meteo - Home Page

The script

Create collect.py. The psycopg import below is v3 (not the older psycopg2 API):

import os
from datetime import datetime, timezone

import psycopg
import requests

API = 'https://api.open-meteo.com/v1/forecast'
PARAMS = {
    'latitude': 52.09,
    'longitude': 5.12,
    'current': 'temperature_2m,wind_speed_10m',
    'timezone': 'UTC',
}

SCHEMA = (
    'CREATE TABLE IF NOT EXISTS readings ('
    'observed_at timestamptz PRIMARY KEY, '
    'temperature_c real NOT NULL, '
    'wind_kmh real NOT NULL)'
)

INSERT = (
    'INSERT INTO readings (observed_at, temperature_c, wind_kmh) '
    'VALUES (%s, %s, %s) '
    'ON CONFLICT (observed_at) DO NOTHING'
)


def fetch_reading():
    response = requests.get(API, params=PARAMS, timeout=15)
    response.raise_for_status()
    current = response.json()['current']
    observed_at = datetime.fromisoformat(current['time']).replace(tzinfo=timezone.utc)
    return observed_at, current['temperature_2m'], current['wind_speed_10m']


def store(reading):
    with psycopg.connect(os.environ['DATABASE_URL']) as conn:
        conn.execute(SCHEMA)
        conn.execute(INSERT, reading)


if __name__ == '__main__':
    store(fetch_reading())

Two habits in this script matter more than the weather:

  • The timeout and raise_for_status() make failures loud. A job that quietly stores garbage is worse than one that fails and tells you.
  • ON CONFLICT DO NOTHING on the observation time makes the job idempotent, safe to run twice without creating duplicates. If a run is retried, or two runs overlap, you get one row. Schedules do occasionally fire twice or get re-run by hand, so design for that from the start.

Put your dependencies in requirements.txt:

requests
psycopg[binary]

The workflow

Create .github/workflows/collect.yml:

name: collect-weather

on:
  schedule:
    - cron: '7 * * * *'
  workflow_dispatch:

concurrency:
  group: collect-weather
  cancel-in-progress: false

jobs:
  collect:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip

      - run: pip install -r requirements.txt

      - run: python collect.py
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

Add the connection string under Settings → Secrets and variables → Actions as DATABASE_URL:

GitHub - Database URL

Push the files, then use Run workflow to test it. A successful collector looks like this: the whole job finished in 17 seconds, most of that installing packages:

GtiHub - Run Workflow

Open Neon’s Tables view and you should see a readings row with the observation time, temperature, and wind speed:

Neon - Database Readings

A few lines in the workflow to make note of:

  • timeout-minutes: 5 stops a hung request from burning your quota. The default timeout is six hours, which is far too generous for a 10-second script.
  • concurrency guarantees that two runs of this workflow never execute at the same time.
  • cache: pip reuses downloaded packages between runs, which shaves seconds off every execution.

That is the entire “server”. Every hour a fresh machine boots, runs the script, writes one row, and disappears.

Two schedule quirks show up only after you are past the first manual run. A new cron does not always start firing right away: the workflow can look active, workflow_dispatch still works, and yet no schedule runs appear for hours. If that happens, push a small change to the workflow file on the default branch so GitHub re-registers the cron, then wait for the next tick and confirm the run’s event is schedule. And on public repositories, scheduled workflows are disabled after 60 days without repository activity: GitHub emails you first, and you can re-enable with a click, but it is a classic way for a “set and forget” job to silently stop.

Example 2: Render an e-ink dashboard

If you only need the collector, skip ahead. The rest of this section turns the same data into a picture for an e-ink screen.

E-ink panels are slow, low-power, and happy to refresh once an hour, which makes them a natural fit for scheduled jobs. Move all the intelligence off the microcontroller:

  • A GitHub Actions job queries the database and renders the final frame with Python.
  • The ESP32 wakes up, downloads the finished frame over HTTPS, pushes it to the display, and goes back to deep sleep.

The device firmware stays tiny and almost never needs updating, while the layout and the data logic live in Python where they are easy to change.

Render the frame

This script targets a 400×300 panel, such as a 4.2-inch e-paper display. Change the size to match yours. The core job is short: load the last 24 hours, draw a black line on white, write a PNG for humans and a raw 1-bit buffer for the device.

Add matplotlib and pillow to the requirements.txt you already created:

requests
psycopg[binary]
matplotlib
pillow

Create render.py with the essentials:

import os
from datetime import datetime, timezone

import matplotlib

matplotlib.use('Agg')
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import psycopg
from PIL import Image

WIDTH, HEIGHT = 400, 300

QUERY = (
    'SELECT observed_at, temperature_c FROM readings '
    'WHERE observed_at > now() - make_interval(hours => 24) '
    'ORDER BY observed_at'
)


def load_readings():
    with psycopg.connect(os.environ['DATABASE_URL']) as conn:
        return conn.execute(QUERY).fetchall()


def render(rows):
    times = [row[0] for row in rows]
    temps = [row[1] for row in rows]

    fig = plt.figure(figsize=(WIDTH / 100, HEIGHT / 100), dpi=100)
    ax = fig.add_axes([0.12, 0.18, 0.84, 0.68])
    ax.plot(times, temps, color='black', linewidth=2)
    ax.set_title(f'Utrecht  |  {temps[-1]:.1f} °C', fontsize=11)
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M', tz=timezone.utc))
    ax.grid(True, linewidth=0.3)
    fig.text(
        0.12,
        0.03,
        f'updated {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC',
        fontsize=8,
    )

    os.makedirs('out', exist_ok=True)
    fig.savefig('out/raw.png', facecolor='white')
    plt.close(fig)

    image = Image.open('out/raw.png').convert('1')
    image.save('out/dashboard.png')
    with open('out/dashboard.bin', 'wb') as f:
        f.write(image.tobytes())


if __name__ == '__main__':
    rows = load_readings()
    if not rows:
        raise SystemExit('No readings yet')
    render(rows)

dashboard.png is for previewing in a browser. dashboard.bin is the same image as raw 1-bit pixels: 400 × 300 ÷ 8 = 15,000 bytes. That is what the microcontroller wants, because decoding a PNG on an ESP32 is far more work than copying bytes into a framebuffer.

The “updated” timestamp doubles as a cheap health check: if the screen shows a stale time, the pipeline has stopped.

A naive convert('1') looks soft on a real panel: anti-aliased lines turn into dither mush, and crowded tick labels collide. The version in the companion repo tightens that for e-ink: render at 2×, threshold instead of dither, fewer UTC ticks, large current temperature, and min/max in the header. Use that file when you wire the display; the snippet above is as an example.

Publish it

The device needs a stable URL to download from. The simplest option is to push the output to a dedicated branch and serve it from raw.githubusercontent.com. Force-pushing a fresh single-commit branch each time avoids filling your repository history with hundreds of binary files.

Create .github/workflows/dashboard.yml:

name: render-dashboard

on:
  schedule:
    - cron: '12 * * * *'   # after the collector has run
  workflow_dispatch:

concurrency:
  group: render-dashboard
  cancel-in-progress: false

permissions:
  contents: write

jobs:
  render:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip

      - run: pip install -r requirements.txt

      - run: python render.py
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      - name: Publish to the eink branch
        run: |
          cd out
          git init -q -b eink
          git config user.name 'github-actions[bot]'
          git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
          git add dashboard.png dashboard.bin
          git commit -q -m 'Update dashboard'
          git push -f 'https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git' eink

Push the files and click Run workflow. A successful render finishes in about 20 seconds and force-pushes the frame to the eink branch:

GitHub - Actions, Render Workflow

After the first run, the binary frame is at:

https://raw.githubusercontent.com/<you>/<repo>/eink/dashboard.bin

The PNG next to it is the human preview. Early on you may only have one reading per hour, so the chart starts as a single point and fills in as the collector accumulates a day of data:

e-ink - Dashboard PNG

This requires a public repository. If yours is private, publish to GitHub Pages or an object storage bucket instead; the rest of the design stays the same. The raw domain caches files for a few minutes, which is irrelevant for an hourly refresh.

The device side

Assuming the board is already on Wi-Fi, this sketch only fetches the frame and deep-sleeps; wiring pins for your panel is out of scope.

For a 4.2-inch Waveshare-style display, start from a MicroPython driver such as mcauser/micropython-waveshare-epaper, then hand frame into that driver’s buffer and refresh.

On the ESP32, the fetch side is almost boring, which is the point:

import machine
import urequests

URL = 'https://raw.githubusercontent.com/<you>/<repo>/eink/dashboard.bin'


def fetch_frame():
    response = urequests.get(URL)
    try:
        return response.content   # 15,000 bytes for a 400x300 1-bit panel
    finally:
        response.close()


frame = fetch_frame()
# Hand `frame` to your display driver, then refresh the panel.
# Some panels expect inverted bits, so you may need to flip them.
machine.deepsleep(60 * 60 * 1000)   # sleep for an hour

The PIL output uses most-significant-bit-first, row-major packing, which matches MicroPython’s framebuf.MONO_HLSB format. Whether a set bit means black or white depends on your panel driver, so check that once and invert in render.py if needed.

One job collects, one job renders, and a battery-powered device sips the result once an hour. No server anywhere.

Keeping scheduled jobs reliable

Scheduled jobs fail silently by default, because nobody is watching. You already put timeouts, concurrency, and idempotency on the collector: copy that pattern onto every workflow.

The same idea shows up in hardware and in servers: something outside the job has to notice that progress stopped, see The Watchdog Pattern.

What’s left is noticing when the schedule itself goes quiet:

  • Notify on failure. GitHub can email you about failed runs, but a push notification is harder to ignore. ntfy.sh is enough for a hobby pager (topic URL in a secret, one curl on failure). I walk through that setup in The 3am Pager. Add a final step that runs only when something broke:
  - name: Notify on failure
    if: failure()
    run: curl -s -d 'collect-weather failed' https://ntfy.sh/${{ secrets.NTFY_TOPIC }}
  • Make the output prove it is alive. The dashboard “updated” timestamp from earlier is the simplest dead man’s switch. For jobs with no visible output, ping a heartbeat service at the end of each run and alert when the pings stop.
  • Pin your actions. @v4 tags are fine for a hobby project. For anything important, pin to a commit SHA so an upstream change cannot alter your job unexpectedly.

What it costs

For public repositories, standard GitHub-hosted runners are free.

For private repositories, the Free plan includes a monthly allowance of Actions minutes (2,000 at the time of writing), and each job is rounded up to the nearest whole minute:

ScheduleRuns per monthMinutes billed
Hourly~720~720
Every 30 minutes~1,440~1,440
Every 15 minutes~2,880~2,880 (over the free allowance)

If you need frequent runs, keep the repository public, combine several tasks into one job, or accept a coarser schedule. Check GitHub’s current pricing page before you commit to a design, because allowances change.

Limits, and when to use something else

GitHub Actions is a great scheduler, but it is not a server. These are the limits:

  • Timing is approximate. If a job must run at exactly 09:00:00, or more often than every five minutes, use something else.
  • There is no persistent disk. Every run starts clean. State must live in a database, object storage, the repository, or the Actions cache.
  • There are no inbound connections. A runner cannot host a service that the outside world talks to.
  • Jobs are capped at six hours, and they are meant to be short.
  • There are terms of service. GitHub describes Actions as being for building, testing, deploying, and publishing your software projects, and it prohibits abuse such as cryptocurrency mining or running a general-purpose compute service. Scheduled tasks that belong to a project, like generating its data or its reports, are the common case. A repository that exists only to rent GitHub’s machines is not. Read the current terms and use your judgment.

If you need an always-on API, a long-running worker, or sub-minute scheduling, a real server (or a managed scheduler from your cloud provider) is the right tool.

Conclusion

The $5 box is still right when something has to stay up and listen. For the jobs that only need to wake up, do one thing, and disappear, a scheduled workflow usually wins: cron, a clean Ubuntu image, secrets, and logs, sitting next to the code that uses them. No disk to babysit. No “what did I install on that machine in 2024?”

The companion code for this article is at github.com/nunombispo/cron-github-actions.

Follow me on Twitter: https://twitter.com/DevAsService

Follow me on Instagram: https://www.instagram.com/devasservice/

Follow me on TikTok: https://www.tiktok.com/@devasservice

Follow me on YouTube: https://www.youtube.com/@DevAsService

Subscription details

You are receiving this because you are a free subscriber to Developer Service Blog — Python, AI & MicroPython by Nuno Bispo.

Name: John Doe

Email: [subscriber]

Member since: 16 October 2026

Thank you for reading and I will see you on the Internet.

Powered by Ghost