GitHub Actions hit our $100 cap. Linux CI now runs on a Hetzner VPS.

Conceptual illustration · not a product screenshot

I hit our 100 USD per month GitHub Actions limit and still had a lot of PR merges sitting there, so I found migrating Linux CI to a Hetzner VPS was the path. This is the guide of how I did it.

What broke

Jobs started dying in 3 to 13 seconds. GitHub said something about payments or the spending limit. I thought we forgot to pay. We didn't. Stop usage was on, the cap is 100 USD, it resets on the 1st, and raising it was off the table.

The last week that still ran (1 August to 7 August, 17:00 UTC) came out to about 155 USD a month, or about 4,489 billable minutes a week. The biggest lines that week:

  • a so-called nightly, about 40 USD. It was wired to push: branches: [main]. It fired 122 times a week, not 7. Median 17.5 minutes.
  • a database-validation suite, about 32 USD
  • PR checks, 23 USD
  • the main test job, 16 USD
  • plus a pile of 1-minute-floor tax on docs, quality, risk, and edge jobs

We later looked at 30 days of healthy runs (20 July to 19 August) and the hosted run-rate was around 160 USD a month. The goal was to get under 80 USD without raising the cap. That one is still open.

One workflow made it worse. A review-coverage job ran on issue_comment next to CodeRabbit replies. 16,199 runs in 30 days. About 121 USD a month. We turned that workflow off on 18 August. After that, real PR CI was still around 110 to 125 USD a month on paper.

About 4,700 of 18,650 minutes a month were jobs under 60 seconds that GitHub still billed as a full minute. That is why a "2x faster hosted runner" would have saved 0 USD on that half. We looked at Blacksmith on 14 August and said no. It needed the repos on a GitHub team.

Hosted CI went dark again on 13 August. Runs came back with no steps in about two seconds. Same cap. As of 26 August, leftover hosted jobs still do not start.

122 vs 7. Not a nightly. Every push to main.

What we moved

The box is in Helsinki. Ubuntu 24.04. 8 AMD EPYC cores. 15 GB RAM. Boot disk plus a 100 GB extra volume for Docker and caches. I am not going to invent the SKU or the monthly euro price. I do not have that invoice in front of me.

Linux CI that now runs there: several product repos, including the SEO tool, Caudal, Lanzalo, Drippy, parts of Regio, a landing site, and a couple of smaller services.

What did not move:

  • one of our app repos. That CI is still on ubuntu-latest. It is still red from the spend cap. It is not on the VPS.
  • two other product repos we left hosted
  • production deploys. Those are still Vercel and Supabase.
  • macOS XCTest
  • Android jobs that need /dev/kvm

If a merge gate still wants a hosted check, that gate stays red. The VPS can be sitting there doing nothing and the queue still will not drain.

Linux CI on the VPS. Deploys, iOS, and our app CI did not move.

How we got there

  • 7 August: hosted CI dies at the cap.
  • 11 August: we started cutting spend and trimmed a workflow. Self-hosted was out of scope until a runner existed.
  • 13-14 August: more cuts, still no GitHub team. Blacksmith was a no.
  • 18 August: turned off the comment-loop workflow.
  • 19 August: went back through 30 days of healthy runs.
  • 21 August: set runner vars on the first three repos.
  • 22 August: five products were moving off hosted. A few watch jobs we left hosted on purpose that day.
  • 23 August: the host filled up and blocked a PR. Cause: 17 copies of the same Deno cache, about 13 GB. We cleaned it, shared the Deno cache, and added a disk-floor script that refuses to start a runner if free space is under 5 GB.
  • 24 August: extra 100 GB volume. Runner units now use KillMode=control-group. KillMode=process had left orphan agents.
  • 26 August: leftover hosted jobs still empty. So all Linux jobs go to the VPS. iOS and KVM stay hosted.

What I would not do again

Leave || ubuntu-latest as the fail-safe while Stop usage is on. One leftover pin and a hosted job sits in queue forever. The VPS is idle. The required check never moves.

Put a comment-posting workflow on issue_comment next to CodeRabbit.

Fire a 17-minute "nightly" on every push to main.

Split one job into many just to go faster. GitHub still bills a full minute per job.

Use KillMode=process on tenant runner units.

Let each job keep its own Deno cache on a shared disk.

Delete the runner variable to roll back one red check. A dead runner leaves required checks pending.

Put untrusted or fork PRs on this private shared host.

Call the VPS a deploy platform. It is CI.

What this is not

I do not have this month's live Actions invoice. I do not have a before and after build-time chart. I will not invent either.

The 80 USD hosted-only target is still open. The thing that actually unblocked Linux CI was the box, not another trim.

I have not tried Omarchy yet. I want to, along with a few other tools. This post is only what we already shipped.

Frequently Asked Questions

Did GitHub Actions get more expensive, or did we use it badly?

Both. The cap is 100 USD with Stop usage on. The last healthy week was about 155 USD a month. A nightly on every main push and a comment-loop next to CodeRabbit did a lot of that. Hosted leftovers still cannot start.

Is our app CI on the VPS?

No. That repo is still ubuntu-latest and still red from the spend cap.

Did deploys move to Hetzner?

No. Production is still Vercel and Supabase. The VPS only runs Linux CI.

What still uses GitHub-hosted runners?

macOS iOS jobs, Android jobs that need KVM, and any workflow we have not moved. Those stay red while the cap is on.

Will a faster hosted runner fix a spend cap?

Not for the jobs that already finish under a minute. GitHub still bills a full minute. We said no to Blacksmith because it needed a GitHub team, not because of speed.

Key takeaways

  • A 100 USD Actions stop-usage cap takes hosted jobs dark. The error looks like a payment failure. The jobs never start.
  • Measure the last healthy week before you say CI is expensive. Ours came out to about 155 USD a month and 4,489 billable minutes.
  • A "nightly" on every main push is not a nightly. Ours fired 122 times a week.
  • issue_comment next to a bot is a cost loop. Ours was about 121 USD a month until we turned the workflow off.
  • Self-hosted Linux unblocked the jobs we actually moved. It did not move deploys, iOS, or our app CI.
  • Do not leave || ubuntu-latest as a fail-safe while the cap is on.

(05) CONTACT

What are youworking on?

A product to build. A system to deploy. A decision to work through.

Start a conversation