Autoscaling GitHub Actions Runners on Kubernetes with ARC

Photo by Mk2010 via Wikimedia Commons (CC BY-SA 4.0)
ARC is the officially supported Kubernetes operator from GitHub that runs and autoscales self-hosted GitHub Actions runners as pods. You install a controller once per cluster, then create one or more runner scale sets. A listener watches the GitHub job queue and creates ephemeral runner pods on demand, scaling back to zero when idle.
Scaling is driven directly by the GitHub Actions job queue, not by CPU or a metrics endpoint. A lightweight listener pod holds a long-poll connection to GitHub; when jobs targeting your runner label are queued, GitHub tells the listener how many runners are needed and it patches the EphemeralRunnerSet so Kubernetes creates that many pods. This makes scaling both fast and accurate.
minRunners is the number of idle runners kept warm and ready, and maxRunners is the hard ceiling on total pods at once. Setting minRunners to 0 gives true scale-to-zero so you pay nothing when idle, at the cost of a cold start on the first job. The running count is roughly minRunners plus active jobs, capped at maxRunners.
Use dind for most teams — it runs a privileged dockerd sidecar and just works for docker build, service containers, and container actions. Use kubernetes mode when your security policy forbids privileged pods; it runs each step as a separate pod but needs a persistent volume and more setup. If your jobs only run scripts and tests, you need neither.
No. Older versions of ARC required cert-manager as a prerequisite, but the modern gha-runner-scale-set controller no longer needs it, so the controller install is a single Helm command. Pair ARC with the Kubernetes Cluster Autoscaler or Karpenter so idle nodes are also removed, not just idle runner pods.

Photo by Mk2010 via Wikimedia Commons (CC BY-SA 4.0)
Key Takeaway
Actions Runner Controller (ARC) runs your GitHub Actions self-hosted runners as ephemeral Kubernetes pods. Its listener watches your job queue and scales runner pods up on queued jobs and back down to zero when idle, so you stop paying for always-on VMs and every job starts on a clean, single-use runner.
I ran self-hosted GitHub Actions runners on plain VMs for a long time, and the two failure modes were always the same. Either a handful of always-on runners sat idle burning money overnight, or a burst of pushes queued jobs for ten minutes because I had capped the machine count. Worse, a persistent runner accumulates state between jobs, so a leftover node_modules, a poisoned Docker cache, or a stray environment variable from a previous build leaks into the next one. Actions Runner Controller fixes both problems at once, and it is the officially supported way GitHub tells you to autoscale runners on Kubernetes.
This post walks through the modern ARC architecture (runner scale sets, not the deprecated RunnerDeployment CRDs), how scaling actually decides how many pods to create, the two container modes you must choose between, and the specific settings that control cost. Everything below is based on the current gha-runner-scale-set Helm charts published by GitHub.
ARC has two Helm charts. The controller chart installs the operator and the custom resources once per cluster. The runner scale set chart is installed once per pool of runners, and each install creates one AutoscalingRunnerSet plus a lightweight listener pod. That listener holds a long-poll connection to the GitHub Actions service. When jobs targeting your runner label get queued, GitHub tells the listener how many runners are needed, and the listener patches the EphemeralRunnerSet so Kubernetes creates that many runner pods. There is no CPU-based HPA and no polling of a metrics endpoint here — scaling is driven directly by the real job queue, which is why it is both fast and accurate.
Every runner ARC creates is ephemeral. It registers with GitHub, picks up exactly one job, runs it, and then the pod is deleted. The next job gets a brand-new pod from the same image. That single-use guarantee is the whole reason to prefer ARC over persistent runners: no state carries over, so a build cannot be sabotaged by whatever ran before it. The cost is cold-start time — each job pays for pulling the runner image and, in dind mode, starting a Docker daemon — which you mitigate with a warm pool via minRunners.
Installation is two Helm releases from GitHub's OCI registry. A big improvement over the old ARC is that cert-manager is no longer a prerequisite, so the controller install is genuinely one command. Install the controller first, then create a Kubernetes secret with your GitHub credentials (a PAT for a quick start, or a GitHub App for anything production), then install the scale set pointing at your repository, org, or enterprise URL.
# 1. Install the controller (once per cluster)
helm install arc \
--namespace arc-systems --create-namespace \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
# 2. Install a runner scale set (once per runner pool)
helm install arc-runner-set \
--namespace arc-runners --create-namespace \
--set githubConfigUrl="https://github.com/MatthewsWongOfficial/my-repo" \
--set githubConfigSecret.github_token="$GH_PAT" \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-setThe Helm release name becomes the runner label. Whatever you call the scale set install is the exact string you put in runs-on. So the release above is consumed by a workflow like this:
# .github/workflows/ci.yml
jobs:
build:
runs-on: arc-runner-set # == the Helm release name
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run buildA PAT is fine for testing but hits GitHub API rate limits fast at scale and expires. For anything real, register a GitHub App and mount its credentials via the secret instead — it gets a much higher rate limit and does not silently die when a token rotates. Migrating later means recreating the secret and a rolling restart, so do it early.
Two values decide your bill. minRunners is the number of idle runners kept warm and ready; maxRunners is the hard ceiling on how many pods can exist at once. Set minRunners to 0 and you scale fully to zero — you pay nothing when no jobs run, at the cost of a cold start on the first job after a quiet period. Set it to 1 or 2 and you trade a little idle cost for near-instant pickup during the workday. The actual running count at any moment is roughly minRunners plus the number of active jobs, capped at maxRunners.
Pair scale-to-zero with the Kubernetes Cluster Autoscaler or Karpenter. ARC removes the runner pods when idle, but the nodes still cost money until something removes them too. With node autoscaling, an all-quiet CI pool can literally drop to zero nodes overnight and spin fresh ones up on the first queued job.
If your workflows only run scripts, tests, and builds directly, you need nothing special. But the moment a job uses container actions, service containers, or docker build, the runner needs a way to run containers. ARC gives you two containerMode options, and the choice has real security and complexity implications.
| Aspect | containerMode: dind | containerMode: kubernetes |
|---|---|---|
| How it works | A privileged dockerd sidecar shares a socket with the runner | Each job step runs as a separate pod via container hooks |
| Privilege | Requires a privileged container | No privileged container needed |
| Setup effort | Low — works out of the box | Higher — needs a persistent volume / storage class |
| Isolation | Weaker; Docker daemon shared within the pod | Stronger; steps isolated at the pod level |
| Best for | Teams that just need docker build to work | Security-sensitive clusters avoiding privileged pods |
My default recommendation is dind for most teams — it is the least surprising and mirrors what a normal runner does. Reach for kubernetes mode only when your cluster policy forbids privileged pods, and budget time for the storage configuration it needs, because it trips people up. One trap worth knowing: recent chart versions have had bugs where the injected dind init container behaves unexpectedly when containerMode is set to kubernetes, so pin your chart version and test the mode you actually chose before rolling it to every repo.
For a small-to-mid team I would install the controller once, run one scale set per environment (a general CI pool and maybe a separate pool for deploys), set minRunners to 1 during work hours and let it fall to zero otherwise, set maxRunners to something safely below cluster capacity, and use dind mode with a GitHub App. That configuration gives clean single-use runners, sub-minute pickup during the day, and a near-zero bill at night. It is dramatically cheaper than a fleet of always-on VMs and far safer than persistent runners, and because scaling is queue-driven it never lags behind a burst of commits the way CPU-based autoscaling does.