Escape GitHub's Free-Minutes Cap: Self-Hosted Runner on Oracle ARM
I burned 1800 of 2000 free Actions minutes in one cycle across a multi-repo setup. Here is how I moved CI off GitHub's billing entirely, for zero rupees.

GitHub emailed me on the 25th of the month: I had used 1800 of my 2000 included Actions minutes. The cycle resets on the 1st. That email is a warning shot, but the part nobody mentions until it hits is what happens at the cap. If you set a spending budget of zero, which any bootstrapped founder does, GitHub does not silently start charging you. It hard-blocks every workflow until the reset. Your deploys stop firing. A push to main that should ship a site just sits there, queued against a quota that will not free up for another six days.
I run several small repos off one GitHub account. A couple of news pipelines on a daily cron, one site that deploys on every push, and a few test workflows. None of it is heavy. All of it runs on the standard Ubuntu runner with no macOS or Windows multiplier. And it still ate the cap. This is the post I wish I had read before that email: the real math, the two ways out, and the exact steps for the permanent fix.
The math that bit me
The 2000 free minutes sound generous until you do the arithmetic against a multi-repo setup. Here is roughly how my 1800 minutes split when I audited the cycle:
| Repo | Approx minutes | Share | What ate it |
|---|---|---|---|
| Site A (content) | ~832 | 43% | Deploy workflow firing on every push |
| Site B (news) | ~629 | 32% | Daily news pipeline cron + deploy on push |
| Site C (news) | ~409 | 21% | Daily news pipeline cron, ~3 runs/day |
| Misc (tests, bots) | ~70 | 4% | Noise |
The single biggest line was the deploy-on-every-push workflow. It ran about 110 times in the cycle at roughly 5.5 minutes a run, and that one workflow alone was over 600 minutes. The daily cron pipelines were the second sink: a 15-minute build that runs once or three times a day quietly compounds. Run a 15-minute job three times daily and you are at 1350 minutes a month from one repo before you deploy anything.
This is the trap with GitHub-hosted runners. Each repo feels free in isolation. The bill is account-wide, and crons plus push triggers across three or four repos converge on the cap faster than your intuition expects.
One auditing gotcha if you try to reproduce these numbers: the per-run timing API lies. Reading .billable.UBUNTU.total_ms from the /runs/{id}/timing endpoint returns 0 for most runs, because that field only counts minutes charged beyond your included allowance. For real usage, read run_duration_ms (or subtract run_started_at from updated_at in the runs list) and round each run up to the whole minute, since GitHub rounds per job. That reproduced my 1800 to within about five percent.
# Count runs in the current cycle for one repo
gh api "/repos/<owner>/<repo>/actions/runs?created=%3E%3D2026-06-01&per_page=1" \
--jq .total_count
The two escapes
There are exactly two clean ways off the cap, and the right answer depends on the workflow.
- Self-hosted runner on a free Oracle ARM box. A self-hosted runner bills zero GitHub minutes. None. The job runs on hardware you already own, so the included-minutes meter never moves. This is the permanent fix for cron pipelines and any build you control end to end.
- Cloudflare Pages native Git integration. If the workflow exists only to build a static site and deploy it, you do not need Actions at all. Connect the repo directly to Cloudflare Pages and CF builds it on its own infrastructure when you push, which does not touch GitHub Actions minutes. The deploy-on-every-push workflow that cost me 600 minutes becomes zero.
I used both. Static-site deploys went to CF Pages native. The cron pipelines, which do real work beyond building HTML, went to a self-hosted runner. The rest of this post is the runner setup, the part with sharp edges.
If you have not stood up the box yet, my Oracle ARM Always-Free walkthrough covers the capacity game and the one networking trap that costs everyone a weekend. This guide assumes you already have an A1 instance breathing.
Setting up a self-hosted runner on a free Oracle ARM box
I run the runner on the same Always Free Oracle A1 box that hosts my Coolify control plane: 4 OCPUs, 24 GB RAM, ARM64, in the Mumbai region. The runner is a lightweight agent that polls GitHub for jobs, so it coexists fine with other services as long as your builds are not RAM-hungry when a deploy fires.
Register the runner from the repo or org settings. GitHub hands you a token and the exact commands; the ARM64 build is the one you want on an Ampere box:
# On the Oracle ARM box, as a non-root user
mkdir actions-runner && cd actions-runner
# Grab the ARM64 (aarch64) runner package, not x64
curl -o actions-runner-linux-arm64.tar.gz -L \
https://github.com/actions/runner/releases/download/<version>/actions-runner-linux-arm64-<version>.tar.gz
tar xzf actions-runner-linux-arm64.tar.gz
# Configure against your repo with the token from Settings > Actions > Runners
./config.sh --url https://github.com/<owner>/<repo> \
--token <RUNNER_REGISTRATION_TOKEN> \
--labels self-hosted,arm64,oracle \
--name oracle-a1-runner
The --labels flag is the part that earns its keep. Labels are how your workflow picks this runner instead of GitHub's. I tag mine self-hosted,arm64,oracle so a job can target the architecture explicitly. In the workflow, swap the runs-on line:
jobs:
build:
# was: runs-on: ubuntu-latest
runs-on: [self-hosted, arm64, oracle]
steps:
- uses: actions/checkout@v4
# ... your build and deploy steps
Do not leave the runner attached to your terminal. Install it as a service so it survives reboots and runs detached:
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status
That registers a systemd service. The runner now picks up jobs on boot, reconnects on its own after a network blip, and shows as Idle in the GitHub UI between jobs.
Security note, non-negotiable. Never enable a self-hosted runner for a public repository that accepts pull requests from forks. A fork PR can change the workflow file or a build script and execute arbitrary code on your runner, which in my case is the same box running production services. Self-hosted runners are for private repos, or public repos with fork pull_request triggers turned off. If you must build fork PRs, use GitHub-hosted runners for that path and keep the self-hosted runner for trusted branches only.
What broke for me: the ARM64 assumption
The gotcha that cost me time was not the runner setup. It was forgetting, again, that this box is ARM64.
Every step in the workflow now runs on Ampere silicon. Anything that pulls a prebuilt binary, a base image, or a native Node module has to resolve an arm64 / aarch64 build, and a fair number of tools still default their install scripts to x86_64. A workflow that ran clean on ubuntu-latest, which is x86, can fail on the self-hosted runner with an architecture or "exec format error" the moment it tries to run an x86 binary. The fix is to make sure every action and install step has an ARM64 path, and to test the pipeline once on the runner before you trust it. The runner itself is fine. The assumptions baked into your build steps are what bite.
The second smaller trap was PATH. A systemd-managed runner does not load your interactive shell profile, so tools you installed into a user-local bin directory may not be on the runner's PATH. If a step works over SSH but fails inside the runner with "command not found," that is the cause. Set the tool path explicitly in the workflow or the runner's environment file rather than relying on your login shell.
What it costs
The runner itself is free. It runs on a box that is already part of the Oracle Always Free tier, so there is no new line item. CF Pages native builds are free too, within Cloudflare's own generous build allowance. My GitHub Actions bill for the cron and deploy work went from "blocked at the cap" to zero billable minutes.
The tradeoff is honest: you now own the uptime. GitHub-hosted runners are someone else's problem to keep alive. A self-hosted runner is yours. If the Oracle box reboots, the runner has to come back with it, which is why installing it as a service and not a screen session matters. If the box is under memory pressure from another service, a build can stall. You traded a managed runner for a free one you babysit. For a bootstrapped operator whose CI does not need dozens of parallel jobs, that is a trade I take every time.
When to just use Cloudflare Pages native instead
The self-hosted runner is the right tool when the pipeline does real work: scraping, generating content, calling APIs, running tests, anything beyond turning source into static files. But if a workflow exists only to build a static site and ship it, the runner is more machinery than the job needs.
Use Cloudflare Pages native Git integration when:
- The workflow is purely build-and-deploy for a static or framework-built site.
- You want zero infrastructure to maintain for that deploy.
- The build fits inside Cloudflare's build environment without custom system dependencies.
Use the self-hosted ARM runner when:
- The job does work beyond building a site, like cron pipelines or test suites.
- You need full control of the build environment and installed tools.
- You are already running an Oracle A1 box and want to put its idle cycles to work.
For the CF Pages side, my Wrangler CLI deep dive covers the deploy tooling, and the Coolify self-hosting walkthrough shows the rest of what shares that same free box. Between the two escapes, my CI stopped costing me a single rupee or a single blocked deploy, and it stays that way every cycle instead of creeping back to the cap on the 25th.
Related
More Automation

Cloudflare API Token Gotchas: The PUT That Wiped Mine Twice
I broke production twice by updating a Cloudflare token's scopes through the public API, then learned the wrangler auth fix and a secret-scrub habit the hard way. This is exactly what bit me and how I handle tokens now.

Fix NVIDIA Cursor and Video Stutter on Linux: GPU Clock Thrash
Cursor jitter and dropped video frames on NVIDIA Linux get blamed on the compositor every time. On my GTX 1660 the real cause was the driver bouncing graphics and VRAM clocks under light load. Here is the fix that held.

Litestream to Cloudflare R2: Disaster Recovery for SQLite
SQLite on one free box is one disk failure away from gone. Here is the exact Litestream-to-R2 setup I run across every PocketBase backend in my stack, including the restore drill and the gotcha that bites first.