How to Set Up Actions Runner Controller (ARC) on Kubernetes (2026)

Nov 10, 2026 · 13 min · Ajay Kumar

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.

ARC runner scale set: from queued job to deleted pod 1. GitHub Job queued with runs-on: arc-linux Runner group must allow the repo 2. Listener pod ns arc-systems Long-polls GitHub Desired = jobs + minRunners 3. Controller ns arc-systems Patches the EphemeralRunnerSet Capped by maxRunners 4. Runner pod ns arc-runners JIT config, one job only dind or kubernetes 5. Cleanup Pod deleted, runner deregistered Back to minRunners (zero by default) Stuck in queued? Walk the boxes left to right: runs-on matches the scale set name, the runner group allows the repo, the listener pod is Running, the controller logs show a scale-up, and runner pods are not Pending. Chart 0.15.1, runner image 2.338.2. Listener and controller live in arc-systems, runners in arc-runners.
The ARC runner scale set lifecycle as described in GitHub's ARC quickstart and deployment docs, with the namespaces used in this guide. The bottom strip is the order I check things in when a job will not start.

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