GitHub's ARC (Actions Runner Controller) lets you run GitHub Actions jobs on your own Kubernetes cluster -- giving you full control over runner images, resource sizing, network policy, and cost. This guide covers scale-set-based ARC v0.14.0, the currently maintained release. It walks through installation, creating your first runner lane, configuring multiple scale sets for different workloads, and the pitfalls that reliably trip teams up the first time. If you are running GitHub-hosted runners today and wondering whether self-hosted makes sense, see GitHub Actions cost breakdown first.
What ARC actually is (and the two generations)
ARC is a Kubernetes operator. It watches for GitHub Actions jobs assigned to your runners and manages the lifecycle of runner Pods in response. Two generations exist, and they are not interchangeable.
Legacy ARC uses actions.summerwind.net CRDs and is community-maintained. GitHub does not provide official support, there is no announced end-of-life, but active development by GitHub has stopped. If you are on legacy ARC and it works for you, it keeps working -- but you will not see new features or official fixes.
Scale-set ARC uses actions.github.com CRDs and is actively maintained by GitHub. This guide covers scale-set ARC exclusively.
The key operational difference: scale sets provision runner Pods on-demand when jobs are queued and scale to zero when idle. The controller is a separate Helm release from the scale sets themselves, which means you install the controller once and can deploy as many named scale sets (runner lanes) as you need without reinstalling the controller.
Prerequisites
Before you start:
- A Kubernetes cluster -- k3s, EKS, GKE, bare metal, or any distribution works
kubectlandhelminstalled and pointed at your cluster- A GitHub App or PAT with the right permissions (see below)
- Outbound internet access from cluster nodes to
api.github.comon TCP 443
GitHub App permissions by registration scope
| Scope | Required permissions |
|---|---|
| Repository | Administration: read/write · Metadata: read-only |
| Organization (recommended) | Self-hosted runners: read/write · Metadata: read-only |
| Enterprise | GitHub Apps not supported -- use PAT with manage_runners:enterprise |
For most teams, org-scope is the right choice. It covers all repos without requiring Administration: write on individual repositories. Adding Administration: write to an org-scoped app is unnecessary and broadens the blast radius if the credential is compromised.
Step 1 -- Install the ARC controller
The controller is a Helm chart published to GitHub Container Registry as an OCI artifact. Install it into its own namespace:
helm install arc \
--namespace arc-systems \
--create-namespace \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
For production, pin a specific version. Minor version upgrades in ARC have required full reinstalls in the past (see pitfalls below), so knowing exactly which version is running matters:
helm install arc \
--namespace arc-systems \
--create-namespace \
--version 0.14.0 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
Verify the controller pod is running:
kubectl get pods -n arc-systems
You should see one arc-gha-runner-scale-set-controller-* pod in Running state.
Step 2 -- Create a scale set (your first runner lane)
A scale set is a named pool of runners. Each scale set is its own Helm release pointing at the same controller. Create a values.yaml with the minimum required config:
# Only these two fields are required -- everything else has defaults
githubConfigUrl: "https://github.com/YOUR_ORG"
githubConfigSecret:
github_token: "<PAT>"
For a GitHub App (preferred over PAT for production):
githubConfigUrl: "https://github.com/YOUR_ORG"
githubConfigSecret:
github_app_id: "<APP_ID>"
github_app_installation_id: "<INSTALLATION_ID>"
github_app_private_key: |
-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----
Two defaults you need to know before deploying:
minRunners: 0-- no pre-provisioned runners. The first job in a cold scale set waits for a Pod to be scheduled, pulled, and registered before it can start.maxRunners: 5-- the hard cap. Five concurrent runners. For any scale set handling real CI traffic, this is almost certainly too low.
Install the scale set:
helm install ci-standard-runc-x64 \
--namespace arc-runners \
--create-namespace \
-f values.yaml \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
The Helm release name (ci-standard-runc-x64 here) becomes the runner name that GitHub registers. This is the string you put in runs-on in your workflow.
Step 3 -- Use the runner in your workflow
Single label (ARC 0.13.x and earlier)
jobs:
build:
runs-on: ci-standard-runc-x64
Multiple labels (ARC 0.14.0+)
ARC 0.14.0 made multi-label support generally available. Set runnerScaleSetLabels in your values.yaml:
githubConfigUrl: "https://github.com/YOUR_ORG"
githubConfigSecret:
github_token: "<PAT>"
runnerScaleSetLabels:
- linux
- gpu
- private-network
Then target the runner in your workflow with a label array. GitHub will route the job to any runner that carries all of the listed labels:
jobs:
build:
runs-on: [linux, gpu, private-network]
Step 4 -- Multiple lane families (recommended for production)
Running all CI through a single scale set creates resource contention and makes it impossible to give different workloads different isolation or resource budgets. Define separate scale sets for meaningfully different workload profiles:
| Lane | Use case | Resources | maxRunners |
|---|---|---|---|
ci-standard-runc-x64 | Lint, unit tests | 2 CPU, 4Gi | 20 |
ci-medium-runc-x64 | Standard builds | 4 CPU, 8Gi | 10 |
ci-large-runc-x64 | Heavy build/test suites | 8 CPU, 16Gi | 5 |
agents-high-runc-x64 | AI agent workloads | 4 CPU, 8Gi, isolated network | 10 |
A values file for the medium lane looks like:
githubConfigUrl: "https://github.com/YOUR_ORG"
githubConfigSecret:
github_token: "<PAT>"
maxRunners: 10
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
resources:
requests:
cpu: "4"
memory: "8Gi"
limits:
cpu: "4"
memory: "8Gi"
Setting requests equal to limits gives runners Guaranteed QoS in Kubernetes. This prevents the scheduler from placing runners on nodes that cannot actually serve the requested resources, which avoids OOM kills and evictions under load.
Cold start behavior
With minRunners: 0 (the default), a runner Pod does not exist until a job arrives. The sequence from job queue to first line of your workflow executing:
- Job queued in GitHub -- listener Pod receives the signal via long-poll
- Controller patches the
EphemeralRunnerSetdesired count up by one - Kubernetes scheduler assigns the Pod to a node
- Container runtime pulls the runner image (if not cached on that node)
- Runner binary starts, registers with GitHub using a JIT token
- Job starts executing
Total overhead is environment-dependent:
- With images pre-pulled on the node: 30-60 seconds of overhead before your first job step runs.
- With a cold image pull: The official
actions/actions-runnerimage is roughly 1-2 GB. On a node pulling it for the first time over a typical cloud network, this adds 1-4 minutes.
To eliminate cold starts entirely for a lane, set minRunners: 1. One runner Pod stays warm and registered at all times, ready to pick up the next job immediately.
Common pitfalls
1. Wrong auth scope permissions
Org-scope does not require Administration: write. The only permissions needed are Self-hosted runners: read/write and Metadata: read-only. Adding Administration: write is a common mistake carried over from repository-scope setups.
2. maxRunners: 5 default is too low for real workloads
The default cap of 5 concurrent runners is a conservative starting point. If you have more than 5 jobs queuing simultaneously, the excess jobs wait in GitHub's queue. Raise maxRunners to match the actual concurrency you need.
3. ARC minor version upgrades require full reinstall
ARC 0.12.0 introduced breaking changes that required removing existing CRDs before upgrading. Before any minor version upgrade, read the release changelog. The safe upgrade path is: helm uninstall the scale sets, helm uninstall the controller, delete leftover CRDs, then reinstall everything at the new version.
4. Flannel does not enforce NetworkPolicy
If your cluster uses Flannel as the CNI, NetworkPolicy objects exist in the API but are silently ignored. For runner lanes that need network isolation, you need Calico or Cilium as your CNI.
When your fleet hits capacity
ARC queues incoming jobs when all runners in a scale set are at maxRunners. Two ways to handle this:
Raise maxRunners -- straightforward but increases compute cost proportionally.
Add hybrid overflow routing -- jobs automatically fall back to GitHub-hosted runners when your fleet is saturated. See hybrid routing explainer for how the routing logic works and how to configure fallback thresholds.
Running ARC in production and want automatic overflow routing to GitHub-hosted runners when your fleet is at capacity? That's what Stratus adds on top.
Join the waitlist →