Cron vs GitHub Actions for Scheduled Jobs

Jonah Hale

Jonah Hale

September 27, 2026

Cron vs GitHub Actions for Scheduled Jobs

For about a year I ran my nightly database backup as a GitHub Actions workflow. It felt tidy. The schedule lived in the repo next to the code, secrets were in the repository settings, and every run left a green or red dot I could see from my phone. Then I tightened the firewall on the database server, and the backup went red. Of course it did: the job ran on a GitHub-hosted runner with an IP address I didn’t control, and the only reason it had worked before was that I’d left the Postgres port open to the whole internet.

I moved the backup back to a crontab on the server itself that evening. A week later I moved a different job the other direction: a script that rebuilt a small static site from a public data feed, which had been running from a crontab on a VPS and pushing to the repo with a deploy key I’d forgotten existed. It now runs as a workflow.

Both moves came from the same realization. “Cron or GitHub Actions” isn’t a question about which scheduler is better. It’s a question about where the job’s inputs and outputs live. A crontab runs on a machine that already has access to things. A scheduled workflow runs on a fresh machine that has access to your repository and whatever you hand it.

What each one actually is

A crontab is a list of lines on a specific computer. At the time you wrote, the cron daemon starts your command as your user, with a minimal environment, in that machine’s timezone. It doesn’t retry. It doesn’t keep logs unless you redirect output. If the machine is off at 3 a.m., the 3 a.m. job doesn’t happen. It has almost no moving parts, and it’s been doing this reliably for decades.

A GitHub Actions scheduled workflow is a YAML file in your repo with an on: schedule block. GitHub reads the cron expression, and at roughly that time it spins up a runner, checks out the default branch, and runs your steps. The runner is gone when the job ends. The schedule is evaluated in UTC, runs from the latest commit on the default branch, and can’t be shorter than every five minutes.

Same five-field cron syntax. Very different machines.

Long aisle of identical servers in a cloud data center under cool white light

The question that decides it: where does the data live?

Here’s the test I use now. For each scheduled job, list what it reads and what it writes.

  • If it reads or writes things that live on a server you run (a local database, files on disk, a service bound to localhost, a mounted volume), it wants to run on that server. Crontab.
  • If it reads public or API-reachable sources and writes back to the repository (a data file, a generated page, a report committed or published as an artifact), it wants to run next to the repo. Actions.

My backup read a Postgres database on a private network and wrote a dump to object storage. Running it from Actions meant opening the database to a rotating set of cloud IPs, or tunneling in, or storing SSH keys in repo secrets that could reach production. Every option widened what a leaked workflow token could touch. Running it from cron on the database host meant pg_dump over a Unix socket. There’s no contest.

The static site job was the opposite. It fetched a public JSON feed, rendered pages, and committed them. On the VPS, it needed a checkout of the repo, a deploy key with write access, and git configured with an identity. In Actions, the checkout is one step, the token is provided per run, and the commit goes back to the same repo it came from. The VPS was an extra machine holding write credentials for no reason.

Where cron is the better scheduler

Timing you can count on

When cron says 02:00, the job starts at 02:00. GitHub’s docs say plainly that scheduled workflows can be delayed during periods of high load, and the top of the hour is the busiest time. I’ve seen runs start 10 to 20 minutes late, and a few that simply didn’t show up. For “refresh this dataset sometime overnight,” that’s fine. For “rotate the log before the 02:15 import reads it,” it isn’t.

Local access with no network exposure

Backups, log rotation, cleaning temp directories, vacuuming a SQLite file, renewing something that talks to localhost: all of these are about the machine itself. Cron is already there.

Short intervals and long jobs

Cron will happily run every minute. Actions won’t go below five minutes, and a job on a GitHub-hosted runner is capped at six hours. If you’re polling something every minute or running a long batch import, the machine you already own is the right place.

No inactivity timeout

This one catches people. In a public repository, GitHub disables scheduled workflows after 60 days with no repository activity. A crontab on a server doesn’t care whether you’ve committed anything lately. For a boring job that should run forever without anyone touching the repo, cron is less likely to quietly stop.

Where GitHub Actions is the better scheduler

The job is about the repo

Dependency reports, link checking, regenerating a README badge, rebuilding a site from content in the repo, pulling an upstream dataset into a committed file. These jobs already need a checkout. Actions gives you one for free with the right permissions scoped to that repository.

You want the history visible

Every run has a log page, a duration, a status, and an optional notification on failure. With cron you have to build that yourself: redirect output to a file, rotate it, and ping something so you know it ran. Actions gives you “did this run, and what did it print” without any setup.

No machine to keep alive

If you don’t already have a server, standing one up just to run a weekly script means patching it, paying for it, and remembering it exists. My old VPS for the site rebuild was exactly that. Actions removed a whole box from my life.

Secrets scoped to the job

Repository or environment secrets are injected at run time and masked in logs. On a server, secrets live in files, and anything else running as that user can read them. For a job that only needs one API key, Actions is cleaner.

External hard drive connected to a small rack server on a wooden shelf under a warm desk lamp

The failure modes, side by side

Both schedulers fail. They fail differently, and knowing how helps you choose.

Cron fails silently. The classic version: the script runs fine when you test it in a shell and fails under cron because cron’s PATH is minimal and node or python3 isn’t where it expects. Output goes to local mail nobody reads. Fix: use absolute paths, set PATH at the top of the crontab, redirect output to a log, and have the job ping a heartbeat URL on success so something notices when it stops.

Cron overlaps. If a job every five minutes sometimes takes seven, you get two copies fighting over the same files. Wrap it in flock -n /tmp/job.lock so a second copy exits instead of piling on.

Cron misses runs when the machine is down. A reboot during the window means that night’s job never happens. A systemd timer with Persistent=true fixes this by running the missed job at boot. On a laptop, anacron does something similar.

Actions fails late or not at all. Delays at busy times, occasional dropped runs, and the 60-day inactivity disable on public repos. None of these show up as a red run. They show up as no run. So you still want an external heartbeat if the job matters.

Actions fails on timezone. Everything is UTC. A job meant for “7 a.m. local” drifts by an hour twice a year if you live somewhere with daylight saving time, because the cron expression doesn’t move and your clock does. Cron on a server follows the server’s timezone, which has its own DST surprises, but at least you can see and set it.

Actions fails on reach. This was my backup. A hosted runner is on the public internet with no special access to your network. The workaround people reach for is a self-hosted runner installed on the server, so the job runs locally but is still scheduled and logged by GitHub. That works, but it turns your production box into a CI machine that executes whatever the workflow file says, which is the self-hosted runner trade-off in its own right. For a single backup job, a crontab line is far less to secure.

What I run where now

After sorting every scheduled job I had, the split looks like this:

Crontab on the server:

  • Nightly pg_dump to object storage, with a heartbeat ping after the upload is verified.
  • Pruning old dumps and old logs.
  • A five-minute check that a queue worker is still processing jobs.

GitHub Actions:

  • The static site rebuild from a public feed, committing changes back.
  • A weekly dependency and license report opened as an issue.
  • A monthly check of external links in the docs.

Notice the pattern. Nothing in the first list needs the repo. Nothing in the second list needs the server. When I catch myself adding SSH keys to repo secrets so a workflow can reach a box, or adding a deploy key to a box so a crontab can push to a repo, I take that as a sign the job is in the wrong place.

A short checklist

  1. Does it touch data on a machine you run? Put it in that machine’s crontab or a systemd timer.
  2. Does it only touch the repo and public APIs? Put it in a scheduled workflow.
  3. Does exact timing matter? Cron. Actions is “roughly then.”
  4. Does it need to run more often than every five minutes, or longer than six hours? Cron.
  5. Will the repo go quiet for months? If it’s public, the schedule may get disabled. Cron doesn’t care.
  6. Either way: have the job report success to something outside itself. Neither scheduler tells you when a job simply didn’t run.

My backup has been on cron for eight months now, with a heartbeat that has fired every night. The site rebuild has been on Actions for about the same time, and the VPS it used to live on is gone. Each one ended up next to the thing it works on, and that has mattered more than which scheduler I picked.

More articles for you