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.
How ARC actually scales: the listener and ephemeral runners
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.
Installing the controller and a scale set
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/matthews-wong/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.
The knobs that control cost
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.
- minRunners: 0 — true scale-to-zero. Best for nightly builds, low-traffic repos, and anything where a 30–60s cold start is acceptable.
- minRunners: 1–2 — a small warm pool so the first push of the morning does not wait. The idle cost is one or two pods, not a full VM fleet.
- maxRunners — set it to protect the cluster, not the queue. A runaway matrix build can otherwise try to spawn hundreds of pods and evict your other workloads.
- resources.requests — right-size CPU and memory per runner pod so the Kubernetes scheduler packs them efficiently instead of over-reserving nodes.
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.
Container mode: dind versus kubernetes
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.
What I would actually deploy
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.

