Skip to main content
BlogDevOps

Autoscaling GitHub Actions Runners on Kubernetes with ARC

How I autoscale ephemeral GitHub Actions runners on Kubernetes with ARC — queue-driven scaling, scale-to-zero, dind vs kubernetes mode, and the settings that actually control cost.

Matthews Wong

Software Engineer

July 20269 min read
Autoscaling GitHub Actions Runners on Kubernetes with ARC

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.

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-set

The 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 build

A 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.

AspectcontainerMode: dindcontainerMode: kubernetes
How it worksA privileged dockerd sidecar shares a socket with the runnerEach job step runs as a separate pod via container hooks
PrivilegeRequires a privileged containerNo privileged container needed
Setup effortLow — works out of the boxHigher — needs a persistent volume / storage class
IsolationWeaker; Docker daemon shared within the podStronger; steps isolated at the pod level
Best forTeams that just need docker build to workSecurity-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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is Actions Runner Controller (ARC)?

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.

How does ARC decide how many runners to create?

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.

What is the difference between minRunners and maxRunners?

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.

Should I use containerMode dind or kubernetes?

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.

Does ARC still require cert-manager?

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.