Scale-to-Zero CI
August 27, 2026
In January my GitHub Actions bill was $41. June was $217, July was $272, and August hit $472 with days still left on the calendar. Three separate times the account blew through its spending limit and GitHub turned CI off entirely. Every open PR red, every deploy stuck, until I logged into the billing page and raised the limit again.
The bill wasn’t from me writing code. It was from my agents writing code. I run a fleet of Claude Code routines that groom backlogs, fix issues, and shepherd PRs around the clock, and every PR they open triggers a CI run on GitHub’s metered runners. More agents meant more PRs, more PRs meant more minutes, and the curve was pointing straight up.
Where the Money Went
All my repos are private, so every CI minute bills. Linux minutes are cheap individually, about half a cent each, but my most active repo burned 45,531 of them in August. The real killer was macOS: Apple-silicon runners bill at roughly ten times the Linux rate, and one path-gated Swift job I’d written off as “rare” turned out to be $217 of the monthly bill all by itself.
GitHub is a fine orchestrator. The workflows, the triggers, the checks on PRs, the logs, all of that works great and none of it costs anything. What I was paying for was the rented computer under each job, and that’s the only part I replaced.
Runners That Only Exist While Working
GitHub doesn’t charge for minutes on runners you bring yourself, so I brought my own. Not a big always-on box, which would just trade a metered bill for an idle one. A fleet that scales to zero, built on the excellent terraform-aws-github-runner module.
A GitHub App watches every repo in my account. When a workflow job queues, the App fires a webhook at an API Gateway endpoint, a Lambda validates it and drops it on a queue, and a second Lambda launches a spot EC2 instance. The instance boots Ubuntu, registers itself as a runner for whichever repo queued the job, chews through the work, and gets terminated after ten idle minutes. During a burst there might be a dozen instances running. When nothing is queued, there are zero, and the whole thing costs pennies of Lambda time.
Adopting it in a workflow is one line: runs-on: ubuntu-latest becomes runs-on: [self-hosted, linux, x64]. And if the fleet ever breaks, the fallback is reverting that line.
Gotchas
The Terraform applied cleanly, 120 resources, webhook verified. Then nothing launched, and everything that went wrong turned out to be a general behavior of EC2 or the module rather than something unique to my setup. If you build this, you’ll want these:
- Never mix Flex and non-Flex instance types in one spot request.
m7i-flex.xlargeandm7i.xlargeshare the same capacity pools, so listing both makes CreateFleet reject the entire request withInvalidFleetConfig: duplicate instance pools. No instance will ever launch and the error only shows up in the scale-up Lambda’s logs. - One subnet per availability zone. Spot pools are keyed by instance type plus AZ, so two subnets in the same AZ duplicate every pool and trigger the same rejection. If you’re using a default VPC that’s accumulated extra subnets, filter the subnet data source to
default-for-az = true. - Use
price-capacity-optimizedallocation, notlowest-price. Lowest price chases the most reclaim-prone pools. AWS took back three of my first twelve instances mid-job, each one failing a CI run that had nothing wrong with it. The price difference is pennies. - Jobs queued while the fleet is broken are lost. A failed scale-up is non-retryable, so those workflow jobs sit queued until GitHub’s 24-hour timeout. Once the fleet is fixed, cancel and re-run them. Waiting does nothing.
- Boot-script changes don’t reach warm runners. Reusable runners keep grabbing re-runs with the old image, including, in one case, an instance that was already shutting down. Let in-flight runs finish, terminate the stale instances, then re-run.
Claude found each of these in the CloudWatch logs faster than I could have opened the console. The whole migration, from “the fleet has never run” to four repos merged and green, was one evening of a Claude Code session driving while I answered the occasional question.
The Missing Tools
GitHub’s hosted image ships with an enormous preinstalled toolchain, and I had never thought about it once. My bare Ubuntu runners made me think about it three times in an hour.
The first job that shelled out to gh died with exit 127, because of course the GitHub CLI isn’t in a stock Ubuntu image. Then a Ruby gem with a Rust native extension failed with fatal error: 'stdarg.h' file not found, which turns out to mean “you don’t have clang.” Then an OpenAPI lint that had been quietly skipping itself started hard-failing instead, because Ubuntu 24.04’s stock Node is 18 and the pinned Redocly CLI wants 20 or newer. Each gap surfaced as a red job, got added to the boot script, and can’t happen again.
The Mac Problem
The one place this doesn’t work yet is macOS. You can’t spot-instance your way to an Xcode build, so I registered runner services on my MacBook Air for the iPhone and Mac app jobs. They were online in minutes, and they buried the laptop I was actively using in about fifteen. I shut them off and put those builds back on GitHub’s hosted Macs while I decide whether a dedicated Mac mini is worth it. At ten times the Linux rate, it probably is.
What It Costs Now
A spot m5.xlarge runs about seven cents an hour, and only while jobs are running. At my volume the fleet projects to $20 to $60 a month, replacing a bill that was on track to clear $500. Deploys stay on GitHub’s hosted runners on purpose: the pipeline that fixes the runner fleet shouldn’t depend on the runner fleet, deploy workflows hold the credentials I least want on shared machines, and those minutes fit inside the free tier anyway.
The agents ran up the bill, and then an agent migrated the bill away, filing the issues, fixing the fleet bugs, and merging the PRs while I mostly watched. I love that. The meter’s off now, and the fleet can churn as hard as it wants.
Instructions for Your Agent
If you want this setup, here’s the brief to hand your coding agent. It’s dense on purpose.
Build a scale-to-zero self-hosted GitHub Actions runner fleet on AWS.
INFRA
- Use the terraform module github-aws-runners/github-runner/aws (v7.11+),
in its own root module and state file, separate from any app infra.
- Create a GitHub App via the manifest flow (POST the manifest to
github.com/settings/apps/new, exchange the redirect code at
/app-manifests/{code}/conversions for the app id + private key).
Permissions: Actions read, Administration read/write, Checks read,
Metadata read. Subscribe to workflow_job. Webhook active with a
placeholder URL (the module's webhook-github-app submodule patches the
real URL and secret at apply time). Install account-wide so every
current and future repo can use the fleet.
- Module config: enable_organization_runners=false for a personal
account (runners register per-repo, dynamically, to whichever repo
queued the job). Ubuntu 24.04 AMI filter + custom userdata (the
module default is Amazon Linux). enable_ephemeral_runners=false,
runners_maximum_count=6, minimum_running_time_in_minutes=10,
scale-down cron every 5 min. 80 GB gp3 root volume. SSM enabled for
debugging, no SSH, no inbound security-group rules.
- instance_types: several interchangeable x86 xlarge types (m7i, m6i,
m6a, m5). NEVER include a *-flex type alongside its sibling.
instance_allocation_strategy = "price-capacity-optimized".
- Subnets: one per AZ, enforced with a default-for-az=true filter.
- Download the module's release lambda zips before terraform plan.
USERDATA (Ubuntu)
- apt: build-essential, clang, libclang-dev, git, jq, pkg-config,
postgresql-client, python3-pip, python3-venv, unzip,
libffi-dev, libicu74, libpq-dev, libssl-dev, libyaml-dev, zlib1g-dev.
- gh from cli.github.com's apt repo. Node 20+ via NodeSource (distro
nodejs is too old for current tooling). AWS CLI v2. Docker rootful
via get.docker.com. CloudWatch agent for runner + boot logs.
- Audit every `run:` step in your workflows for binaries they shell out
to. Anything missing surfaces as a red job, not a warning.
WORKFLOW FLIP
- Change runs-on: ubuntu-latest to runs-on: [self-hosted, linux, x64]
in CI workflows only. Add timeout-minutes to EVERY job. Ensure
concurrency with cancel-in-progress on PR workflows.
- Open the flip as a PR: pull_request events use the PR's own workflow
file, so the PR is the end-to-end smoke test. Merge only when green.
- Leave deploy workflows on hosted runners: OIDC trust policies pin
literal workflow paths, the pipeline that repairs the fleet must not
depend on the fleet, and deploy credentials don't belong on shared
reusable runners. Leave macOS jobs on hosted runners unless you have
a dedicated Mac, and never let PR jobs share a runner with one that
holds signing credentials.
VERIFY
- Watch the scale-up lambda's logs for successfulRunnerCount >= 1 and
the instance ids. Jobs queued while scale-up was failing are dropped:
cancel and re-run them. After any userdata change, recycle idle
instances so re-runs land on the new image.
– John