> ## Content Index
> Fetch the complete content index at: https://developer-service.blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# Stop Paying for Cron: Running Scheduled Jobs on GitHub Actions
- URL: https://developer-service.blog/stop-paying-for-cron-running-scheduled-jobs-on-github-actions/
- Published: 2026-11-02T07:55:48.000Z
- Updated: 2026-11-02T07:56:36.000Z
- Description: 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.
- Author: Nuno Bispo
- Tags: Python

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](https://developer-service.blog/github-actions-for-python-projects-automate-your-workflow-from-day-one/) for the vocabulary (`workflow`, `job`, `step`, `runner`), then come back here for the schedule trigger.

That trigger takes a standard cron expression:

```yaml
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](https://neon.tech/?ref=developer-service.blog). 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:

![](https://developer-service.blog/content/images/2026/11/neon-create-project.png)

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:

![](https://developer-service.blog/content/images/2026/11/neno-database-url.png)

Neon - Database URL

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

![](https://developer-service.blog/content/images/2026/11/open-meteo-homepage.png)

Open Meteo - Home Page

### The script

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

```python
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`:

```yaml
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`:

![](https://developer-service.blog/content/images/2026/11/github-database-url.png)

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:

![](https://developer-service.blog/content/images/2026/11/github-collect-action-run.png)

GtiHub - Run Workflow

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

![](https://developer-service.blog/content/images/2026/11/neon-database-readings.png)

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:

```python
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](https://github.com/nunombispo/cron-github-actions/blob/main/render.py?ref=developer-service.blog) 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`:

```yaml
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:

![](https://developer-service.blog/content/images/2026/11/render-github-action-run.png)

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:

![](https://developer-service.blog/content/images/2026/11/eink-dashboard.png)

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](https://github.com/mcauser/micropython-waveshare-epaper?ref=developer-service.blog), then hand `frame` into that driver’s buffer and refresh.

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

```python
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](https://developer-service.blog/the-watchdog-pattern-from-esp32-to-python/).

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](https://ntfy.sh/?ref=developer-service.blog) is enough for a hobby pager (topic URL in a secret, one `curl` on failure). I walk through that setup in [The 3am Pager](https://developer-service.blog/the-3am-pager-a-scrappy-llm-cost-monitor-with-python-and-ntfy-sh/). Add a final step that runs only when something broke:

```yaml
  - 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:

| Schedule         | Runs per month | Minutes 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](https://github.com/nunombispo/cron-github-actions?ref=developer-service.blog).

---

Follow me on Twitter: [https://twitter.com/DevAsService](https://twitter.com/DevAsService?ref=developer-service.blog)

Follow me on Instagram: [https://www.instagram.com/devasservice/](https://www.instagram.com/devasservice/?ref=developer-service.blog)

Follow me on TikTok: [https://www.tiktok.com/@devasservice](https://www.tiktok.com/@devasservice?ref=developer-service.blog)

Follow me on YouTube: [https://www.youtube.com/@DevAsService](https://www.youtube.com/@DevAsService?ref=developer-service.blog)