How to Set Up Actions Runner Controller (ARC) on Kubernetes (2026)
This guide is for platform and DevOps engineers who want ephemeral GitHub Actions runners on a Kubernetes cluster they already operate. At the end you will have the Actions Runner Controller (ARC) controller running, an organisation-level runner scale set authenticated with a GitHub App, locked to a runner group, scaling from zero to a cap you choose, with either Docker-in-Docker or the Kubernetes container mode, plus Prometheus metrics and a checklist for jobs stuck in "queued". Everything is pinned to ARC chart 0.15.1 (released 23 October 2026) and runner image 2.338.2. I rendered every values file below with helm template against the 0.15.1 charts; I did not run them against your cluster, so do a staging install first. You need Kubernetes 1.29 or newer (ARC runs the Docker daemon as a native sidecar there), Helm 3, kubectl, the gh CLI, and organisation owner rights on GitHub. If you are still deciding whether to self-host at all, read GitHub Actions self-hosted runners in 2026 first; it covers pricing, the postponed $0.002 per minute charge and the alternatives to ARC.
How ARC runner scale sets work
ARC has two Helm charts. gha-runner-scale-set-controller installs the CRDs and the controller once per cluster. gha-runner-scale-set is installed once per pool of runners; each install registers a runner scale set with GitHub and creates an AutoscalingRunnerSet. A listener pod per scale set holds a long-poll connection to GitHub, learns how many jobs are assigned, and asks the controller to create that many EphemeralRunner pods (plus minRunners idle ones). Each pod gets a just-in-time registration, runs exactly one job, and is deleted.
Step 1: Create a GitHub App for ARC
GitHub recommends a GitHub App rather than a personal access token for repository and organisation scale sets (enterprise-level scale sets still need a classic PAT). In your organisation go to Settings, Developer settings, GitHub Apps, New GitHub App. Give it any name, set the homepage URL to your org, untick Webhook (ARC does not use webhooks), and grant the permissions from the ARC authentication docs:
| Scale set level | Repository permissions | Organisation permissions |
|---|---|---|
| Organisation (this guide) | Metadata: Read-only | Self-hosted runners: Read and write |
| Single repository | Administration: Read and write; Metadata: Read-only | Self-hosted runners: Read and write |
Create the app, note the App ID (or client ID) on its settings page, generate a private key (a .pem file downloads), then install the app on your organisation. The installation ID is the number at the end of the installation URL, or you can fetch it:
# Requires org owner (classic tokens need admin:read); prints the installation id for your app
gh api orgs/acme-org/installations --jq '.installations[] | "\(.app_slug) \(.id)"'
Step 2: Install the ARC controller with a pinned chart
Both quickstart commands in GitHub's docs install the latest chart if you omit --version. Pin it. Controller and scale sets must run the same version.
# Controller, CRDs and RBAC. One per cluster.
helm install arc \
--namespace arc-systems \
--create-namespace \
--version 0.15.1 \
-f controller-values.yaml \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
A small controller-values.yaml that turns on metrics and quieter logs (the chart default log level is debug):
# controller-values.yaml for gha-runner-scale-set-controller 0.15.1
metrics:
controllerManagerAddr: ":8080" # controller metrics
listenerAddr: ":8080" # each listener pod exposes metrics here
listenerEndpoint: "/metrics"
flags:
logLevel: "info" # chart default is "debug"
logFormat: "json" # easier to ship to Loki or OpenSearch
Check it:
kubectl get pods -n arc-systems
# Expected output (illustrative; pod suffix will differ):
# NAME READY STATUS RESTARTS AGE
# arc-gha-rs-controller-7d9c8b6f5d-x2k4q 1/1 Running 0 40s
kubectl get crds | grep actions.github.com
# autoscalinglisteners.actions.github.com
# autoscalingrunnersets.actions.github.com
# ephemeralrunners.actions.github.com
# ephemeralrunnersets.actions.github.com
The deployment and service account are named arc-gha-rs-controller when the release is called arc. You will need that name in Step 5.
Step 3: Store the GitHub App credentials as a Secret
Keep the key out of Helm values and shell history. Create the runner namespace and a pre-defined secret with exactly these three keys. IDs are strings.
kubectl create namespace arc-runners
kubectl create secret generic arc-github-app \
--namespace arc-runners \
--from-literal=github_app_id=123456 \
--from-literal=github_app_installation_id=654321 \
--from-file=github_app_private_key=./arc-runners.2026-11-10.private-key.pem
# Then delete the local .pem or move it into your secrets manager
If you already run External Secrets Operator, sync the same three keys from Vault or your cloud secret store instead; the pattern is in External Secrets Operator with Vault. ARC also supports Azure Key Vault natively through the keyVault chart value.
Step 4: Create a runner group restricted to private repos
Runner groups are the boundary that stops an unexpected repository (or a public one) from landing jobs on your cluster. They need the GitHub Team plan or higher for organisations. The REST API defaults allows_public_repositories to false; set it explicitly anyway so the intent is in your script.
# Look up the repository ids that may use these runners
gh api repos/acme-org/api-service --jq .id # e.g. 812345678
gh api repos/acme-org/web-frontend --jq .id # e.g. 812345999
# Create the group, limited to those repositories
gh api -X POST orgs/acme-org/actions/runner-groups \
-f name=arc-private \
-f visibility=selected \
-F allows_public_repositories=false \
-F 'selected_repository_ids[]=812345678' \
-F 'selected_repository_ids[]=812345999'
You can go further with restricted_to_workflows=true and a selected_workflows list, so only named workflow files on named refs can use the group. That is worth doing for runners that can reach production networks.
Step 5: Install a runner scale set with Docker-in-Docker
Docker-in-Docker (dind) is the mode to start with if your workflows run docker build or use services:. It adds a privileged docker:dind sidecar to every runner pod. That is a real isolation cost: treat the runner nodes as untrusted, and keep them in their own node pool away from production workloads.
# runners-dind.yaml for gha-runner-scale-set 0.15.1
githubConfigUrl: "https://github.com/acme-org" # org-level scale set
githubConfigSecret: arc-github-app # Secret from Step 3
runnerScaleSetName: "arc-linux" # value for runs-on
runnerGroup: "arc-private" # group from Step 4
minRunners: 0 # scale to zero when idle
maxRunners: 20 # hard cap on concurrent jobs
containerMode:
type: "dind" # privileged Docker sidecar
controllerServiceAccount: # explicit, avoids discovery errors
namespace: arc-systems
name: arc-gha-rs-controller
template:
spec:
nodeSelector:
workload: ci-runners # dedicated node pool
containers:
- name: runner # do not rename
image: ghcr.io/actions/actions-runner:2.338.2
command: ["/home/runner/run.sh"]
resources:
requests: { cpu: "2", memory: 4Gi }
limits: { memory: 8Gi }
helm install arc-linux \
--namespace arc-runners \
--version 0.15.1 \
-f runners-dind.yaml \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
When I rendered this file with helm template the chart produced an AutoscalingRunnerSet named arc-linux with runnerGroup: arc-private, minRunners: 0, maxRunners: 20, an init container copying runner externals, and a dind sidecar with privileged: true and restartPolicy: Always. If you leave out controllerServiceAccount and the chart cannot find the controller, it fails with "No gha-rs-controller deployment found using label".
The installation name must be 45 characters or fewer and the namespace 63, because ARC uses generated names as labels. Verify the listener came up:
kubectl get pods -n arc-systems -l app.kubernetes.io/component=runner-scale-set-listener
# NAME READY STATUS RESTARTS AGE
# arc-linux-754b578d-listener 1/1 Running 0 25s (names will differ)
kubectl get autoscalingrunnersets -n arc-runners
Step 6: Target the scale set from a workflow
The scale set name is the runs-on value. No self-hosted label is needed.
# .github/workflows/ci.yaml
name: ci
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: arc-linux
steps:
- uses: actions/checkout@v7
- run: docker version # talks to the dind sidecar
- run: docker build -t api:${{ github.sha }} .
Watch a runner appear and disappear:
kubectl get pods -n arc-runners -w
# Expected sequence (illustrative; names and timings will differ):
# arc-linux-abc12-runner-7xk9p 0/2 Pending 0 0s
# arc-linux-abc12-runner-7xk9p 0/2 Init:0/2 0 2s
# arc-linux-abc12-runner-7xk9p 2/2 Running 0 9s
# arc-linux-abc12-runner-7xk9p 0/2 Completed 0 3m10s
kubectl get ephemeralrunners -n arc-runners
Since ARC 0.14.0 a scale set can also carry several labels so jobs can use runs-on: [linux, x64, docker]. GitHub's workflow docs call the setting runnerScaleSetLabels, which is the field name on the AutoscalingRunnerSet; in the 0.15.1 Helm chart the value you set is scaleSetLabels, and the template copies it into runnerScaleSetLabels.
# add to runners-dind.yaml (chart key; each label under 256 characters)
scaleSetLabels: ["linux", "x64", "docker"]
Step 7: Use kubernetes container mode instead of privileged DinD
If you do not want privileged pods, kubernetes mode uses the runner container hooks to start each job's container: and services: as separate pods in the same namespace. Every job then needs a container: because ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER is true by default. The mode needs a storage class that can provision a volume per runner:
# runners-k8s.yaml for gha-runner-scale-set 0.15.1
githubConfigUrl: "https://github.com/acme-org"
githubConfigSecret: arc-github-app
runnerScaleSetName: "arc-k8s"
runnerGroup: "arc-private"
minRunners: 0
maxRunners: 10
containerMode:
type: "kubernetes" # or "kubernetes-novolume" with no PVCs
kubernetesModeWorkVolumeClaim:
accessModes: ["ReadWriteOnce"]
storageClassName: "gp3" # any dynamic provisioner
resources:
requests:
storage: 10Gi
controllerServiceAccount:
namespace: arc-systems
name: arc-gha-rs-controller
# a job for kubernetes mode
jobs:
test:
runs-on: arc-k8s
container:
image: node:24-bookworm
steps:
- uses: actions/checkout@v7
- run: npm ci && npm test
kubernetes-novolume mode, added for clusters without shared storage, moves the job filesystem between pods with lifecycle hooks instead of a PVC. Note the trade: the runner's service account gets a Role (I checked the rendered 0.15.1 chart) that can create and delete pods and secrets and exec into pods in arc-runners, so keep that namespace for CI only and apply Pod Security Standards to it. Kubernetes mode cannot build images with the Docker CLI; use a daemonless builder such as BuildKit rootless or Kaniko if you need builds there.
Step 8: Tune autoscaling, scale to zero and metrics
With minRunners: 0, nothing runs until a job is assigned, so the first job pays for a pod start plus image pull. Set minRunners: 2 during working hours if that latency matters; idle runners count toward maxRunners. ARC has no built-in schedule, so change the values from a CronJob or your GitOps tool. Setting both values to 0 drains the scale set: queued jobs wait, no new pods start.
# Change scaling limits on an existing scale set (same pinned chart version)
helm upgrade arc-linux \
--namespace arc-runners \
--version 0.15.1 \
-f runners-dind.yaml \
--set minRunners=2 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
Upgrading the controller itself is different. Helm does not upgrade CRDs, so GitHub's deployment docs say to uninstall every scale set, wait for their resources to go, uninstall the controller, replace the actions.github.com CRDs if they changed, and reinstall everything at the new version. Do it in staging first.
Pair ARC with a node autoscaler so runner pods trigger new nodes and idle nodes go away; Karpenter on EKS works well with a dedicated NodePool for workload: ci-runners. With the metrics block from Step 2, scrape the listener for gha_assigned_jobs, gha_running_jobs, gha_idle_runners and the gha_job_startup_duration_seconds histogram. Startup duration is the number that tells you whether minRunners: 0 is costing developers time. Leave the opt-in job_workflow_ref label off unless your Prometheus can handle a new series per branch.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Job sits at "Waiting for a runner to pick up this job" | runs-on does not match runnerScaleSetName (or labels) |
Use the exact scale set name; check kubectl get autoscalingrunnersets -A |
| Job queued, no runner pod, listener idle | Runner group does not include the repo, or repo is public and allows_public_repositories is false |
Add the repo id to the group; keep public repos on hosted runners |
| Scale set installed but no listener pod | Wrong githubConfigUrl or bad githubConfigSecret keys |
Read controller logs: kubectl logs -n arc-systems -l app.kubernetes.io/name=gha-rs-controller |
401 Unauthorized getting a GitHub App token |
Clock drift on nodes, wrong app or installation id, or wrong key | Fix NTP on nodes; recreate the Secret with string ids and the current .pem |
Runner pods stay Pending |
No node fits the requests, nodeSelector mismatch, or PVC unbound in kubernetes mode | kubectl describe pod; check node pool, autoscaler and storage class |
Cannot connect to the Docker daemon in a job |
Scale set not in dind mode, or custom template dropped the sidecar |
Set containerMode.type: dind, or copy the full dind spec from the chart values |
| Kubernetes mode job fails without running steps | Job has no container: while ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER is true |
Add container: to the job; only set the variable to "false" if you accept the extra privileges |
Access to the path /home/runner/_work/_tool is denied |
Non-root runner and volume ownership mismatch | Set template.spec.securityContext.fsGroup: 123, or chown the work dir in an init container |
Name must have up to 45 characters |
Installation name too long | Shorten the Helm release name; namespace limit is 63 |
| Jobs stop being queued to runners after weeks | Pinned runner image older than the 30-day update window | Bump ghcr.io/actions/actions-runner to the latest release and roll the scale set |
Two more that confuse people. After a workflow is cancelled, ARC may briefly start replacement runner pods before the listener sees the cancellation; that is documented behaviour and they go away. And if resources hang on delete during an uninstall, it is usually because the controller was removed before the scale sets, leaving the actions.github.com/cleanup-protection finalizers with nothing to process them: reinstall the controller at the same version, uninstall the scale sets, wait, then remove the controller.
FAQ
Can ARC scale GitHub Actions runners to zero?
Yes, and it does by default. With minRunners unset or 0, a scale set has no runner pods until GitHub assigns a job, then creates one pod per job up to maxRunners. Only the controller and one listener pod per scale set stay running.
Should I use Docker-in-Docker or kubernetes container mode?
Use dind if workflows need docker build, docker run or services: with no changes, and accept that runner pods are privileged. Use kubernetes or kubernetes-novolume if you can require a container: on every job and build images with a daemonless tool. Many teams run one scale set of each and route jobs with runs-on.
Can I use a personal access token instead of a GitHub App?
Yes, through githubConfigSecret.github_token, and enterprise-level scale sets require a classic PAT. For organisation and repository scale sets GitHub recommends a GitHub App, which has narrower permissions, and is not tied to a person who might leave.
How do I upgrade Actions Runner Controller?
Not with a plain helm upgrade of the controller, because Helm does not upgrade CRDs. Uninstall the scale sets, uninstall the controller, update the CRDs, and reinstall both charts at the same new version. Scale set value changes, such as minRunners, can be applied with helm upgrade on that release.
When a job will not start in production, I check in the order of the diagram: the exact runs-on string, the runner group's repository list, the listener pod's status and logs, then whether runner pods are Pending for want of a node. Most of the time it is one of the first two. I also keep the runner image pinned and bumped monthly by Renovate, because the 30-day update rule fails quietly. For anything deeper, the official sources are the ARC quickstart, the runner scale set deployment reference, GitHub's secure use guidance for self-hosted runners and the ARC release notes.
Related: GitHub Actions Self-Hosted Runners in 2026, How to Set Up Karpenter on EKS: NodePools, Spot and Consolidation (2026) and GitHub Actions Security in 2026: 76 Hijacked Tags and the Fixes That Work.
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Build an Azure Pipelines YAML Pipeline: Step-by-Step (2026)
Build an azure-pipelines.yml from scratch in 2026: stages, jobs and steps, reusable templates, variable groups linked to Key Vault, a workload identity federation service connection, environments with approvals, and deployment to AKS or App Service, plus fixes for the common errors.
13 minOct 31, 2026How to Migrate Jenkins to GitHub Actions: Step-by-Step Guide (2026)
Migrate Jenkins pipelines to GitHub Actions in 2026: inventory jobs, plugins and credentials, run GitHub Actions Importer audit, forecast and dry-run, map Jenkinsfile stages to jobs, move keys to secrets and OIDC, rebuild shared libraries as reusable workflows, and run ARC 0.15.1 runners.
16 minNov 10, 2026How to Deploy Airflow 3 on Kubernetes with the Helm Chart (2026)
Step-by-step guide to running Apache Airflow 3.4 on Kubernetes with the official Helm chart 1.22.0: KubernetesExecutor vs Celery, git-sync Dags, external Postgres with PgBouncer, Vault secrets, KubernetesPodOperator and upgrading from Airflow 2.
14 min