GitHub Actions Self-Hosted Runners on Kubernetes
Running Kubernetes-based CI/CD saves money and lets you customize infrastructure to your needs.

GitHub Actions runs 71 million jobs a day and consumed 11.5 billion minutes in 2025. At that scale, per-minute billing on GitHub-hosted runners becomes a line item somebody in finance asks about. That's the pressure point where teams start looking at Kubernetes and the Actions Runner Controller (ARC), and this piece covers what ARC actually is, how it schedules work, and what it takes to run it safely in production.
Why teams outgrow GitHub-hosted runners and move to their own infrastructure
Cost is the obvious trigger, but it's rarely the only one. GitHub-hosted runners bill by the minute, and once a team is running thousands of jobs a day, that bill compounds fast. Money aside, hosted runners can't reach anything behind a firewall. A job that needs to talk to an on-premises database or an internal API either needs a complicated tunnel setup or it just doesn't work on a hosted runner.
Hardware is another wall. GitHub-hosted runners don't offer GPUs, ARM chips, or other specialized accelerators, so any workload that needs one of those has nowhere to go. Customization is limited too: teams stuck installing the same tools at the start of every job watch minutes disappear into apt-get before the actual work even starts.
Then there's concurrency. Hosted runners impose a hard concurrency ceiling that varies by plan, and once a team's pipeline volume bumps against it, jobs sit in a queue instead of running. Data compliance rules that keep certain workloads from ever leaving a company's own network make self-hosted infrastructure non-optional for some teams.
Moving to Kubernetes changes the operating model in a specific way: instead of long-lived virtual machines that need patching and babysitting, each job gets an ephemeral pod that spins up, runs the job, and disappears. No persistent state, no drift between one job and the next.
None of that makes the move a philosophical stance. It's a practical decision that should show up on the calendar the day concurrency or cost actually becomes the bottleneck, not before. Standing up Kubernetes infrastructure before you need it just adds operational weight for no return.
Actions Runner Controller and how GitHub positions it
ARC is GitHub's own Kubernetes operator for self-hosted runners. It handles the full lifecycle: provisioning a runner, running the job on it, and cleaning it up afterward. It can register runners at the repository, organization, or enterprise level, so a single controller can serve very different slices of an org's GitHub setup.
There are two versions of ARC in the wild, and the distinction matters before anyone runs a Helm install. The original, community-maintained ARC still exists, but GitHub only supports it through community contributions in the ARC repository at this point. The version GitHub actually stands behind is the Autoscaling Runner Sets model, packaged as the gha-runner-scale-set Helm chart. This guide covers that version exclusively, because it's the one with a supported future.
ARC 0.14.0 announced the deprecation path away from the legacy controller, so any team still running the old version needs a migration plan.
Runner version matters too. GitHub had planned to permanently reject self-hosted runners older than v2.329.0 starting March 16, 2026, then paused that enforcement at the last minute. Full enforcement for GitHub Enterprise Cloud was later rescheduled to September 25, 2026. For ARC users, that translates into a concrete requirement: staying current on a supported controller version, since outdated runners will be rejected. Teams should verify their specific GitHub deployment's enforcement timeline directly with GitHub.
GitHub is upfront about who ARC is for. It's recommended for organizations that already run Kubernetes and already have the operational chops to keep a cluster healthy. ARC is a way to point existing Kubernetes expertise at a new problem, not a way to learn Kubernetes. It's a way to point existing Kubernetes expertise at a new problem.
How ARC's architecture turns a job queue into running pods
ARC's install splits cleanly across two namespaces. The controller manager pod, which runs the operator logic, lives in its own namespace. Runner pods live somewhere else entirely. Keeping those separated is sound practice: the controller's permissions and the runner's permissions are different concerns, and mixing them in one namespace blurs that boundary.
The listener pod is where the actual scaling decision gets made. It holds a long-poll connection open to GitHub, and when a job targeting that scale set's label gets queued, GitHub tells the listener how many runners are needed to handle it. The listener then patches the EphemeralRunnerSet, and Kubernetes takes it from there, creating however many pods the patch calls for.
The scaling signal is queue depth, not CPU or memory. A cluster autoscaler built around resource utilization would miss this signal entirely, because a runner pod might sit at low CPU usage right up until the job starts.
The lifecycle from there follows a fixed path. A job gets assigned, a pod gets created, the job runs, and once it's done the runner pod is cleaned up by the controller. If an assigned runner doesn't pick up its job within 60 seconds, GitHub re-queues the job. If a job sits queued for more than 24 hours without an available runner, it fails outright.
Installing ARC and creating a runner scale set
Before installing anything, a cluster needs three things: a Kubernetes cluster on a supported distribution (OpenShift support landed in public preview with ARC 0.12.0), Helm 3, and a GitHub authentication credential, either a GitHub App or a personal access token.
The controller install itself is one Helm command:
helm install arc --namespace arc-systems --create-namespace oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
Anyone following an older tutorial should note that the modern gha-runner-scale-set controller doesn't require cert-manager as a prerequisite anymore. Earlier versions did, and that's a common source of confusion for people working from outdated guides.
Authentication needs to be thought through rather than copied. A GitHub App is the recommended path for repository and organization runners: it requires Administration (read/write) permission for repo-level runners, appropriate self-hosted runner permissions for org-level runners, and Metadata (read) across the board. The app ID, installation ID, and private key get stored as a Kubernetes secret in the runner namespace. A classic personal access token is simpler to set up but carries broader permissions than the scoped alternative, and it is required for enterprise-level scale sets; teams should consult GitHub's current documentation for token scope options if they prefer to avoid classic tokens where possible.
Wherever the credential lives, the secret has to sit in the same namespace as the runner scale set it authenticates, not the controller's namespace.
With the controller running and a secret in place, the runner scale set install is a second Helm command, pointing at the runner chart and passing in githubConfigUrl (the repo, org, or enterprise URL) along with githubConfigSecret.
Configuring autoscaling: minRunners, maxRunners, and scale-to-zero
Two settings govern how ARC scales, and they pull in opposite directions. minRunners sets how many idle runners stay warm and ready even when nothing's queued; it cuts job wait time at the cost of paying for idle compute around the clock. maxRunners sets the ceiling, the maximum number of runner pods that can run at once, and it exists to protect both cluster capacity and GitHub's API rate limits from getting overwhelmed by a burst of jobs.
Setting minRunners to 0 gives a cluster true scale-to-zero behavior: no idle pods sitting around when the queue is empty. The tradeoff is cold-start latency on the first job after a quiet stretch, since a pod has to be scheduled and started before anything runs. For workloads that only fire overnight or a few times a day, that tradeoff is usually the right one.
Teams where job latency actually matters, where a developer waiting on a pull request check cares about every extra second, should keep minRunners at 1 or higher instead. The right pool size matches typical burst volume.
The math ARC uses to decide the target runner count is simple: idle minimum plus jobs currently assigned to that scale set. That formula explains why a cluster with minRunners set to 3 will always show 3 runner pods sitting there even with zero active jobs. That's not a bug, it's the setting doing exactly what it says.
Container modes and their security and compatibility costs
ARC offers three container modes, and the choice between them is really a choice about how much privilege a CI job gets inside the cluster.
Docker-in-Docker, set with containerMode: dind, runs a Docker daemon as a sidecar alongside the runner. It's the most compatible option: any workflow that builds a container image or spins up service containers works the way it would on a runner backed by a conventional virtual machine. The cost is that it requires privileged mode in Kubernetes, and both the runner process and the Docker daemon run as root inside that container. That's the detail platform teams need to sit with before turning DinD on by default, because privileged containers are one of the more direct paths from a compromised CI job to a compromised node.
Kubernetes mode, containerMode: kubernetes, drops the Docker daemon from the pod entirely, which means no privileged containers at all. Any workflow written with the assumption that a container runtime is available inside the job will need rework, a compatibility cost visible when containers are involved.
No-volume mode, containerMode: kubernetes-novolume, arrived in ARC 0.13.0. It uses lifecycle hooks to restore and export a job's filesystem between pods without needing a shared ReadWriteMany volume, relying on local storage instead. That improves both portability and performance for teams that found RWX volume provisioning to be a headache in their cluster.
ARC 0.12.0 also fixed a real operational problem with DinD: previously, the DinD sidecar and the runner container had independent lifecycles and could exit out of order, which caused messy failures. Version 0.12.0 adopted Kubernetes' native sidecar feature, on by default since Kubernetes v1.29, to keep the two containers' lifecycles in sync.
None of this is a decision to make by default. A team that accepts DinD without reviewing its pod security policies is granting every CI job a path to a privileged container, whether or not that job ever needed one. Workflows that build container images should evaluate DinD alongside compensating controls, workflows that are pure compute (tests, linting, artifact builds) should use Kubernetes mode and avoid granting privileged containers entirely, and teams that care most about storage simplicity and portability should look at no-volume mode as the newest and most targeted option.
Security hardening for production runner deployments
Authentication choice sets the security baseline. A GitHub App scopes its permissions precisely, doesn't depend on a specific human user's account, and rotates more cleanly than a token tied to someone's login. That's why it's the preferred option for production. Classic PATs remain simpler to configure, and they are required for enterprise-level scale sets, so some teams don't get a choice.
Credential storage is the next layer. By default, GitHub App or PAT credentials sit as Kubernetes secrets in the runner namespace. ARC 0.12.0 added a public preview feature for vault-based secret retrieval, with Azure Key Vault supported in this preview, letting teams pull credentials dynamically instead of storing them as static Kubernetes secrets. Since it's a preview feature, teams should validate it thoroughly in a non-production cluster before leaning on it for anything that matters.
Namespace isolation between the controller and the runners isn't just an organizational nicety, it's a security boundary. Least-privilege RBAC on the runner service account should follow from that: a runner pod has no business reading cluster secrets outside its own job or touching resources beyond what that job requires.
Ephemerality is the last piece, and arguably the most important one. Every runner pod gets destroyed after a single job. There's no persistent state for one job's output, or one job's compromise, to leak into the next. On a shared cluster where multiple teams' jobs run through the same infrastructure, that's the property doing the most work to keep one team's pipeline from becoming another team's problem.
Custom runner images and resource configuration
Building a custom runner image pays off when a job needs the same tools every single run. Pre-installing language runtimes, build tools, internal certificates, or CLI utilities into the image avoids the same apt-get sequence running at the start of every job, and it makes cold-start time predictable instead of a function of whatever package mirror happens to be slow that day.
The pattern is simple: start from ghcr.io/actions/actions-runner:latest as the base image, install what's needed as root, then switch back to the runner user before the image finishes. That last step matters, since it keeps the runner's own entrypoint logic intact.
Not everything belongs baked into the image, though. Stable, heavy dependencies, things like Node, Python, kubectl, Helm, or the Docker CLI, are worth including permanently. Anything project-specific, or anything that changes often, is better installed as a workflow step, where it can be updated without rebuilding and redistributing an image across a fleet of runners.
Resource sizing is its own discipline. Every runner scale set spec should set both requests and limits on the runner container: requests are what the Kubernetes scheduler uses to decide where a pod fits, and limits are what stop a runaway job from starving its neighbors. Size those numbers to the job's actual profile rather than a generous guess, because an oversized request quietly shrinks how many runners the cluster can schedule at once, even if the job never uses that much memory. For a baseline, GitHub-hosted ubuntu-latest runners provide 7 GB of memory and 2 CPU cores, which gives migrating teams a reasonable starting point to size against.
Observability, version management, and keeping the controller current
ARC 0.13 brought metrics improvements aimed at giving operators better visibility into scaling behavior and runner health, closing some of the gap between "the cluster is doing something" and "here's exactly why it's doing that." Combined with the queue-depth-based scaling logic covered earlier, decent observability is what turns ARC from a black box into a system an on-call engineer can actually reason about at 2 a.m.
Version discipline matters more with ARC than with most Kubernetes operators, given the runner version floor GitHub has already announced and the deprecation timeline moving teams off the legacy controller. Staying on 0.14.0 or later isn't just a best practice at this point, it's the baseline for staying connected to GitHub.com at all once enforcement for GitHub Enterprise Cloud lands in September 2026. Teams running ARC in production should treat controller upgrades the same way they treat any other piece of security-relevant infrastructure: on a schedule, not an afterthought.


