Your CI Bill Shouldn't Grow With Every Agent Commit

Before cutting CI checks to control GitHub Actions costs, compare ephemeral self-hosted runners. Our ARC experience, the trade-offs, and where to start.

github-actions self-hosted-runners platform-engineering ci-cd cost-optimization
13 min read 2,555 words
Your CI Bill Shouldn't Grow With Every Agent Commit

How we made private-repository CI boring with ephemeral runners, Ansible, and infrastructure we already had.

TL;DR

  • Busy private-repository CI can justify buying predictable compute capacity instead of paying for every execution minute.
  • We use ephemeral ARC runners on K3s, with deployment and lifecycle operations captured in Ansible and a Makefile.
  • The decision needs your actual bill, workload benchmarks, peak concurrency, and operating costs—not a cheap-server screenshot.

More code. More pushes. The same meter running.

Now a days every developer uses coding agents to speedup development time. So changes arrive faster, pull requests keep moving, and CI spends the working day building, testing, and starting again.

Then Billing Manager opens the GitHub Actions bill.

In an October post on X, Jan Wilmake described a GitHub CI bill that had grown from USD 0 to USD 380 per month and was heading toward USD 800. Coding agents were pushing around 300 times a day, and the test suite had grown fivefold. His response: stop running CI on pull requests and move full tests to nightly runs and release PRs.

Sanju replied that his team had been paying $900 a month and agreed with the diagnosis.

Those are their reported numbers, not our invoices. But the pattern is familiar: the bill tracks activity, not value. Every extra push, every new test file, every agent retry adds to the meter — while the work it protects stays the same.

We want faster development and fast feedback, then treat the infrastructure bill as a reason to separate the two. Cancel superseded runs. Avoid duplicated workflows. Don’t rebuild everything for a documentation change. Those are sensible optimizations. But before you remove useful checks, ask a different question:

Is the problem how much feedback you need OR how you’re buying the compute that provides it?

Keep GitHub Actions. Reconsider where the jobs run.

This isn’t an argument for replacing your CI platform.

GitHub Actions can remain where developers define workflows, review failures, and enforce required checks. The execution machines don’t have to belong to GitHub. For a team with occasional builds, hosted runners are a convenient purchase. You avoid maintaining machines and pay for execution when you need it.

For an organization with sustained daytime demand across private repositories, that calculation deserves another look. A pool of machines can serve many jobs over the month. Within its capacity, another commit doesn’t create another server rental charge. There is still a ceiling. Fill the pool and jobs queue; need more capacity and you buy it. Predictable infrastructure spending is not unlimited compute.

The audience I have in mind is specific: engineering managers and platform leads paying meaningful runner overages, with workloads that can run on Linux and an organization willing to own the execution environment. If your included minutes already cover your needs, or your workflows depend heavily on managed macOS environments, the answer may be different.

Our first runners weren’t boring

The earlier VM-based runner arrangement had the problems you would expect from machines repeatedly doing build work: polluted environments, memory pressure, OOM kills, and runner or host corruption. Moving a runner onto your own server doesn’t make those problems disappear. It moves responsibility for them onto you.

The useful change was making the execution environment disposable, then making the infrastructure reproducible. The important part isn’t that Kubernetes appeared in the architecture. It’s that running more jobs stopped meaning accumulating more state on long-lived runner VMs.

Existing infrastructure helped our economics. It wasn’t free hardware, and it isn’t proof that every newly rented cluster will produce the same result.

What we automated instead of babysitting

The setup uses K3s and GitHub Actions Runner Controller, or ARC. Our cloud inventory targets Hetzner or DigitalOcean.

The cloud layout separates a tainted control plane from dedicated workers. Scale sets select appropriate worker capacity through scheduling configuration, including architecture and resource requirements.

GitHub Actions jobs
        |
        v
ARC controller + scale-set listeners
        |
        v
Ephemeral runner pods on K3s workers
        |
        +-- Runner container
        +-- Docker-in-Docker sidecar
        +-- Per-pod temporary working storage

ARC manages runner capacity within configured minimum and maximum bounds. Kubernetes schedules the pods onto available workers. The runner template gives the runner and its Docker sidecar explicit CPU and memory requests and limits.

Our working directories and Docker-related volumes use emptyDir: pod-scoped storage rather than a permanent workspace shared by successive runner pods. That helps prevent yesterday’s build state from becoming tomorrow’s mysterious dependency.

Different jobs don’t need identical runners

Cost isn’t the only benefit. Separate runner scale sets let you match the execution environment to the job, with independent minimum and maximum runner counts. Workflow jobs can target the appropriate scale-set name through runs-on; runner groups control which repositories can access those pools.

For example, you can design pools for:

  • Linting and unit tests: smaller runners without a Docker daemon when the jobs don’t need one.
  • Docker builds and container-based integration tests: runners configured with Docker-in-Docker (DinD).
  • Memory-heavy test suites: larger memory requests and limits, scheduled onto suitable workers.
  • Architecture-specific builds: separate ARM64 and x64 pools for compatible workloads.

DinD is a per-pool design choice, not something every CI job must inherit. A dedicated non-DinD pod template avoids starting an unnecessary Docker sidecar and granting its privileged execution. Jobs that use Docker-based actions or service containers still need a compatible execution configuration.

Each scale set can grow or shrink its runner count with demand inside its configured bounds. Different pools can also have different resource budgets and scheduling constraints, so lightweight checks don’t need the same runner shape as a container-heavy integration suite. § ARC scale-set configuration

This is runner-pod autoscaling, not automatic procurement of more servers. Pools still compete for finite worker capacity unless you deliberately separate or reserve it; the worker fleet must cover the concurrency you want.

The lifecycle is code, not someone’s shell history

Ansible holds the provisioning and deployment steps. A Makefile exposes the operations we need to repeat.

For example, these are actual targets from our setup, not commands that work on an arbitrary fresh server:

make deps
make cloud-configure-cp
make cloud-k3s-cp
make cloud-k3s-worker
make cloud-arc-controller
make cloud-arc-scaleset
make cloud-k8s-check

The playbook separates control-plane configuration, K3s installation, worker joins, and ARC deployment. Inventories describe the target cluster and runner scale sets. That matters during handover. The next engineer shouldn’t need to reconstruct a machine from terminal history to understand how it was built.

Routine upgrades have an explicit path

The repository also contains System Upgrade Controller setup and K3s upgrade plans. They define a weekly window, cordon nodes, and limit upgrade concurrency to one node per plan; the agent plan waits for the server plan.

That is automation for K3s upgrades—not a claim that every OS package, container image, credential, and dependency maintains itself. In our experience, the settled system needs near-zero routine manual intervention. We aren’t constantly entering the cluster to nurse individual runners back into service.

That is the benefit I care about: recurring work handled by controllers and documented procedures, rather than recurring attention from an engineer.

There is nothing to back up

We do not back up the CI machines, and that is a design decision rather than an omission. The nodes hold no state worth restoring: the inventory and playbook describe them, the runner images are disposable, and every job starts on a fresh pod.

Recovery is bringing the Actions infrastructure back up with the same targets that built it:

make cloud-configure-cp
make cloud-k3s-cp
make cloud-arc-controller
make cloud-arc-scaleset

Three or four commands, depending on whether the old control plane still exists. That is the RTO — not a snapshot restore, not a warm standby.

The failures are part of the evidence

The low-touch experience came after solving real problems.

One was Docker networking. The documented overlay path used a smaller MTU than Docker’s default bridge. Nested containers could produce packets that didn’t fit, causing confusing failures. Developed template explicitly configures the DinD MTU rather than leaving that mismatch implicit.

Other recorded issues included memory pressure, resource-quota interactions, GitHub download limits, and architecture-specific dependency failures.

These details matter because “self-healing” has boundaries. Controllers can reconcile runner resources and Kubernetes can replace failed pods when capacity and dependencies are available. Neither can repair an incorrect network design, conjure more RAM, or make an incompatible binary run on ARM.

Automation removes repeated intervention. It doesn’t remove engineering judgment.

Compare the whole bill—not server stickers

GitHub’s current documentation lists the standard private-repository Linux runner at 2 vCPU, 8 GB RAM, 14 GB SSD, billed at $0.006 per minute. Included standard-runner minutes are 2,000 on GitHub Free, 3,000 on Team, and 50,000 on Enterprise Cloud. Larger runners have separate rates and cannot use that allowance. Self-hosted execution is currently described as free, with maintenance costs belonging to you. § Billing documentation · Runner rates · Runner specs

Check those terms again before modeling anything. GitHub announced a per-minute platform charge for self-hosted runners, then postponed it; that single number can change your answer. Pricing moves.

Per-minute rates are easy to compare, but they hide what you are actually buying. Normalized per vCPU-hour:

Runner (Linux)vCPU / RAMPer minutePer vCPU-hour
Standard (private repos)2 / 8 GB$0.006$0.180
Larger, 4-core4 / 16 GB$0.012$0.180
Larger, 8-core8 / 32 GB$0.022$0.165
Larger, 16-core16 / 64 GB$0.042$0.158
Larger, 4-core arm644 / 16 GB$0.008$0.120

Two things fall out. A bigger runner buys you less wall-clock time, not a cheaper vCPU-hour. And the per-minute rate is the only lever GitHub gives you on the bill.

A realistic mix, not one runner size

The premise that every job can share the smallest instance doesn’t survive contact with a monorepo. But memory alone is rarely what forces a bigger runner — the standard private-repo runner already has 8 GB. The real constraints are 2 vCPUs, a 14 GB disk, and wall-clock time. A Docker build, a Playwright suite, or a test run across forty packages wants more of all three.

So price the two pools that actually exist in a monorepo: cheap checks, and expensive builds. This section assumes x64 Linux on GitHub Team — 3,000 included minutes — and Hetzner EU list prices. No enterprise allowance anywhere in the numbers; on GitHub Free the allowance is 2,000, which changes the total by a few dollars.

First, the arithmetic that makes this look bigger than it is. A month is 730 hours, or 43,800 minutes of wall clock. Concurrency means you serve more job-minutes than that: 95,000 job-minutes is about 2.2× the wall clock, or roughly seven jobs running at once during a 10-hour working day.

Pool 1 — lint, typecheck, unit tests

About 10 minutes per job, 55,000 minutes a month. That is roughly 5,500 jobs. Across 22 working days and a 10-hour window, that is about 4 jobs running at once.

RunnerMinutesRateCost
Standard 2 vCPU55,000$0.006$312

52,000 of those minutes are billable after the 3,000-minute Team allowance.

Pool 2 — Docker builds, integration, e2e

20 to 30 minutes per job, 40,000 minutes a month. Roughly 2,000 builds at 20 minutes each. In the same window, about 3 running at once, with spikes above that.

RunnerMinutesRateCost
Larger 4-core / 16 GB40,000$0.012$480

Total metered: $792 on Team, $798 on Free. Larger runners never touch the allowance, so every build minute is billed. That asymmetry is where the bill comes from.

Two node types, one per pool

NodeSpecPoolCountEUR/month
CPX324 vCPU, 8 GBlint2€71.98
CPX428 vCPU, 16 GBbuilds (DinD)2€139.98
Total€211.96 (~USD 229)

Eight vCPU for lint, sixteen for builds: enough for the average concurrency above, with a short queue at genuine spikes. No monitoring line, no backup line, no platform-team line — just servers.

Where CI runsGitHub Actions billInfrastructureMonthly totalSaving
Hosted — Team (3,000 included)$792—$792—
Hosted — Free (2,000 included)$798—$798—
Self-hosted — buy the two node types$0~$229~$229$563 (~71%)
Self-hosted — reuse unutilized servers$0~$0 incremental~$0$792 (~100%)

The last row is not literally free — you are consuming headroom you might need for something else, and the hardware depreciates. But the incremental cash outlay is zero, and it is the version most teams actually have sitting in a rack.

In our case: the capacity already exists and is idle, the infrastructure line mostly drops out. That is our case, and it is why our numbers moved when the workload did.

Hetzner and DigitalOcean are both worth evaluating. § Hetzner cloud · DigitalOcean Droplets. Benchmark representative jobs on the proposed hardware before trusting any of this. Measure wall-clock duration, memory peaks, image downloads, and queue time under concurrency.

Audit before you cut checks

Start with your bill and a representative period of workflow history.

Find the repositories and runner types driving paid usage. Separate execution from storage. Look at peak concurrency as well as total minutes: a fleet that finishes everything eventually can still be a bad developer experience.

Then pilot a representative workload. Include its Docker behavior, architecture requirements, secrets, and required checks. Compare runtime and queue time against your current setup before moving the rest.

Our experience is that self-hosted CI doesn’t have to become a second full-time job. Ephemeral runners, explicit resource boundaries, reproducible deployment, and automated lifecycle operations made the difference.

Before you remove a test to save runner minutes, check whether you’re buying those minutes the wrong way.

Want me to build this for your team?

If your GitHub Actions bill keeps growing, let’s look at whether self-hosted runners would save your team money—without trading away fast feedback.

If there’s a good fit, you can hire me on a contract basis to implement it: runner infrastructure, automation, workflow migration, validation, and documented handover. You don’t have to turn this article into another project for your already-busy platform team.

The goal is a lower bill and CI your team doesn’t have to babysit—not another cluster to worry about.

I shared the alternative approach here:

Dipankar Das

Dipankar Das

Building sustainable and efficient platforms without firefighting