Home /

Free cron jobs: who actually runs the script?

Choose an HTTP trigger or a script runner, with a complete report job, failure recovery and the limits that change the choice.

  • URL schedulers call an existing backend; GitHub Actions starts a runner for repository scripts.
  • The local report preserves its previous file after failure; hosted runner output needs a separate durable destination.

A free cron job can mean two different things: a service that calls your URL, or a machine that starts your script. Choose that part first. A schedule attached to an HTTP request will not install Python or give your code somewhere to live.

For an existing short endpoint, cron-job.org is a straightforward option. For a script kept in a repository, GitHub Actions supplies the runner as well as the schedule, provided delayed or missed runs are acceptable. QStash becomes useful when the endpoint needs retrying delivery, with a separate budget for delivery attempts.

The provider details below were checked on 27 October 2026. The report example was exercised locally, including failures and overlapping calls. No provider account, live schedule or GitHub Actions run was created for this guide.

Where the work happens

What you haveRoute to considerWhat still belongs to you
Code already served by an HTTP endpointcron-job.org calls the URL, as often as once a minuteHosting, endpoint authentication and the task itself
A small repository-owned scriptGitHub Actions starts a runner; schedules have a five-minute minimumScript, dependencies, input and somewhere to keep the result
An endpoint that needs retries or controlled deliveryQStash schedules HTTP messagesHosted handler, duplicate-safe work and its execution limits

For the first row, cron-job.org lets you set the HTTP method, headers and body. Its FAQ lists a 30-second wait and a 64 KB response limit. Return a small result. A disconnected request does not prove that your host stopped the underlying work, so do not automatically retry a non-repeatable operation just because the scheduler timed out.

There is a transport detail to check before sending credentials: the same FAQ says cron-job.org does not check HTTPS certificates. If verified TLS is part of your endpoint's requirements, do not assume this service satisfies them merely because the URL begins with https://.

QStash Free lists 1,000 messages per day and ten active schedules. Each delivery attempt counts, including retries and each destination in a group. Once a minute means 1,440 initial attempts a day, already above that listed allowance. Its 15-minute response limit also cannot extend your app host's execution time. Schedule less frequently or choose a different allowance; accepting a cron expression is not the same as fitting the free plan.

QStash can deliver a message more than once, even after successful processing. A handler that replaces a report with the same desired result is easier to retry than one that sends a payment or appends another email.

A script that keeps the last good report

Here is a small recurring task: count open and closed issues from an exported snapshot. It has no external API dependency, so you can check its behavior before adding credentials or a scheduler. Save this as issues.json:

{
  "date": "2026-10-27",
  "issues": [
    {"id": 1, "state": "open"},
    {"id": 2, "state": "closed"},
    {"id": 3, "state": "open"}
  ]
}

The date describes the export. Running the file tomorrow will not fetch tomorrow's issues or change that date; a real workflow needs a fresh export as its input.

Save the following as job.py. It uses Python's standard library and runs on macOS or Linux. fcntl is Unix-specific, so this version does not run unchanged on Windows.

#!/usr/bin/env python3
"""Generate a deterministic report. Python 3, macOS/Linux; no dependencies."""
import argparse
from datetime import date
import fcntl
import json
import os
from pathlib import Path
import sys
import tempfile


def generate(fixture, output):
    output = Path(output)
    output.parent.mkdir(parents=True, exist_ok=True)
    # Keep this lock file: deleting it could split concurrent callers' locks.
    with output.with_name(output.name + ".lock").open("a") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        data = json.loads(Path(fixture).read_text(encoding="utf-8"))
        if not isinstance(data, dict) or set(data) != {"date", "issues"}:
            raise ValueError("expected date and issues fields")
        if not isinstance(data["date"], str) or date.fromisoformat(data["date"]).isoformat() != data["date"]:
            raise ValueError("date must be YYYY-MM-DD")
        if not isinstance(data["issues"], list):
            raise ValueError("issues must be a list")
        counts, seen = {"open": 0, "closed": 0}, set()
        for issue in data["issues"]:
            if not isinstance(issue, dict) or set(issue) != {"id", "state"}:
                raise ValueError("each issue needs id and state")
            ident, state = issue["id"], issue["state"]
            if type(ident) is not int or ident < 1 or ident in seen:
                raise ValueError("issue ids must be unique positive integers")
            if not isinstance(state, str) or state not in counts:
                raise ValueError("state must be open or closed")
            seen.add(ident)
            counts[state] += 1
        report = {"date": data["date"], "total": len(seen), "by_state": counts}
        temporary = None
        try:
            with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=output.parent, delete=False) as handle:
                temporary = handle.name
                handle.write(json.dumps(report, sort_keys=True, indent=2) + "\n")
                handle.flush()
                os.fsync(handle.fileno())
            os.replace(temporary, output)
        finally:
            if temporary and os.path.exists(temporary):
                os.unlink(temporary)
        return report


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("fixture", type=Path)
    parser.add_argument("output", type=Path)
    args = parser.parse_args()
    try:
        generate(args.fixture, args.output)
    except (OSError, ValueError, TypeError, RecursionError) as error:
        print(f"report failed: {error}", file=sys.stderr)
        sys.exit(1)

Run it twice:

python3 job.py issues.json report.json
cat report.json
python3 job.py issues.json report.json

The fixture produces three issues: two open and one closed. Repeating it produces the same report bytes. Invalid JSON, a repeated issue ID, an invalid date or an unknown state returns a nonzero exit code without replacing the existing report. Fix the input and rerun the same command to recover.

The temporary file is written beside the destination, then replaced with os.replace. Readers get the previous complete file or its replacement rather than a half-written report. A retained lock file serializes cooperating writers on that machine. This is not a distributed lock, and neither mechanism makes external side effects exactly-once. The example also makes no crash-durability promise for the filesystem.

Local verification exercised the actual CLI and a separate localhost HTTP adapter: 17 checks passed on Python 3.14.6/macOS. Those included eight CLI processes waiting behind a held lock, twelve concurrent HTTP calls, rejected authentication, invalid input, recovery and an injected replacement failure. They establish the example's local behavior, not scheduler punctuality or hosted reliability.

Give the repository a schedule

For a repository-owned report, put job.py and issues.json at the root and save this workflow as .github/workflows/report.yml. The example below only prints the report; it neither commits it nor publishes it.

# Assumes job.py and issues.json have been copied into a repository's root.
name: Daily issue report
on:
  workflow_dispatch:
  schedule:
    - cron: '17 7 * * *'
      timezone: Europe/London
permissions:
  contents: read
concurrency:
  group: daily-issue-report
  cancel-in-progress: false
jobs:
  report:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      # checkout v7.0.1
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
        with:
          persist-credentials: false
      - name: Build report
        run: python3 job.py issues.json report.json
      - name: Show report
        run: cat report.json
# Runner files disappear after the job. This example only prints the report.
# Use separately chosen durable storage if another process needs the result.

GitHub added native timezone support in March 2026. This schedules 07:17 in Europe/London, including the normal GMT/BST change, rather than a fixed UTC hour. The workflow documentation describes spring-forward schedules in skipped hours advancing to the next valid time. Choosing a morning time outside the clock-change hour avoids that particular ambiguity.

The workflow must exist on the default branch and scheduled runs use that branch. GitHub warns that load can delay or drop scheduled jobs, especially near the start of an hour. Public-repository schedules are disabled after 60 days without repository activity. The manual workflow_dispatch trigger gives you a way to run the job when checking or recovering it; it is not a guarantee that a missed scheduled run will be replayed.

Standard GitHub-hosted runners are free in public repositories. Private repositories use the owner’s included compute usage and storage allowances; larger runners have their own rates. Keep the task within GitHub's current usage terms and the account's budget. A scheduled workflow is useful for repository automation, not a promise of unlimited general hosting.

A hosted runner's working files do not become durable application storage. In this workflow the report is visible in the run log, then the runner is discarded. If another service needs to consume the report, choose an artifact or storage destination with its own retention and access rules. For an output that must survive a failed next run, the previous result must live there too. The local file-preservation guarantee cannot preserve a file that was on yesterday's discarded runner.

If the job already lives behind a URL

Keep the HTTP handler on the existing backend and point the scheduler at that deployed address. 127.0.0.1 on your laptop is not reachable from an external scheduler. A request configuration for a handler that accepts a small JSON object might be:

Method: POST
URL: https://your-existing-backend.example/jobs/report
Authorization: Bearer <a server-side secret>
Content-Type: application/json
Body: {}

The handler should authenticate before starting work, use server-controlled input/output paths, reject oversized or unexpected bodies, and return success only after the promised work completes. Keep tokens out of URLs and request logs. If the handler queues work and returns 202, that acknowledges acceptance; check the eventual job result separately.

The local adapter used for this guide followed those rules and returned 401 for missing or wrong credentials, 500 for an invalid fixture and 200 after recovery. It is a development server, not a public hosting recipe. Deploying it would require an appropriate server/proxy and a separate check of timeouts, TLS and operational limits.

For a task that must finish every day, add a completion signal. Healthchecks.io's cron monitoring expects a ping from the job and alerts when it does not arrive within the configured schedule and grace period. Send success after the report has been stored where its consumer needs it. That catches a task that never starts as well as one that starts and fails.

The free-compute guide covers places to host the backend if you do not already have one. For this report, first choose where the fresh export comes from and where the finished JSON needs to survive. Those two destinations determine whether a repository runner or an existing HTTP backend is the useful free cron route.