Self-hosted GitHub Actions runners: when the maths works and how not to get owned
Hosted minutes stop being cheap somewhere around 100,000 a month. Running your own runners on Kubernetes is straightforward; running them safely takes three specific decisions.
The case for self-hosted runners is almost always one of three things: the hosted bill, builds that need more memory or a GPU, or jobs that must reach something inside your VPC. Each has a different answer, and the security implications are the same in all three.
When the maths works
Hosted Linux minutes are billed per minute with a per-minute price that is very hard to beat for low volume — a runner you provision yourself has idle time, a control plane and an engineer attached to it.
The crossover in our experience sits around 80,000 to 150,000 Linux minutes a month, and arrives much earlier if:
- You need large runners. Hosted large runners scale price roughly with size; a spot instance of the same size costs a fraction.
- You build on macOS. The ×10 multiplier makes even a small amount of macOS the biggest line on the bill. Self-hosting macOS means physical Macs or a dedicated-host provider, which is real work, but the savings are large enough to justify it at moderate volume.
- Your jobs pull large artefacts. Data transfer and cache round-trips inside your own network are free and fast; from hosted runners they are neither.
Before provisioning anything, get the number: the billing page gives minutes per workflow. Multiply your top five workflows by their frequency and you usually find that 70 percent of the bill comes from one or two jobs, and that making those faster is cheaper than moving them.
Actions Runner Controller
For Kubernetes, ARC is the supported path. It watches for queued jobs and creates an ephemeral runner pod per job.
# values for the gha-runner-scale-set chart
githubConfigUrl: https://github.com/acme
githubConfigSecret: arc-github-app
minRunners: 0
maxRunners: 60
containerMode:
type: dind
template:
spec:
nodeSelector: { workload: ci }
tolerations:
- key: ci
operator: Exists
effect: NoSchedule
containers:
- name: runner
image: ghcr.io/actions/actions-runner:2.330.0
resources:
requests: { cpu: "2", memory: "4Gi" }
and workflows select it by name:
jobs:
build:
runs-on: arc-linux-x64
minRunners: 0 plus spot nodes with a taint is the configuration that actually saves money: nodes exist only while jobs are queued. Expect 30 to 60 seconds of cold-start when the node pool scales from zero; minRunners: 2 during working hours buys that back for the price of two idle pods.
Authenticate ARC with a GitHub App, not a personal access token. A PAT ties your CI fleet to one person's account and inherits every permission they have.
Three decisions that keep you from getting owned
1. Ephemeral, always. A runner that handles two jobs lets the first job leave something behind for the second — a modified ~/.gitconfig, a poisoned node_modules, a background process reading the next job's secrets. ARC's runner scale sets are ephemeral by default. Do not turn that off to make caching easier; use a remote cache instead.
2. Never on public repository pull requests. This is the one that bites companies. A fork PR can modify the workflow and run arbitrary code on your infrastructure, inside your network, with whatever the node's instance profile grants. If a repository is public, keep its PR builds on hosted runners. If you must self-host them, run in a separate account and cluster with no network path to anything.
3. No ambient cloud credentials on the node. A runner pod that can reach the IMDS endpoint inherits the node's IAM role, and every job on that cluster has it. Block it:
# on the node pool
--http-put-response-hop-limit=1
plus a NetworkPolicy denying egress to 169.254.169.254. Jobs get cloud access through OIDC federation scoped to the repository and environment, which is auditable and expires in an hour.
The same logic applies to the cluster itself: give runner pods their own namespace, their own service account with no Kubernetes API permissions, and a node pool that nothing else shares. Treat the CI cluster as untrusted, because it runs code from every developer in the company.
What it actually costs to run
Budget for: the cluster or node pool, a maintained runner image (rebuild weekly, pin the runner version), ARC upgrades every couple of months, and someone on call when the fleet stops picking up jobs at 9am on a Monday.
For most teams that is a few hours a month once it is stable, and the savings are real. For a team with 20,000 hosted minutes a month, it is not worth it — pay the bill and spend the time elsewhere.