Setting Up Actions Runner Controller (ARC) v2 on Kubernetes
Install ARC v2 on a Kubernetes cluster, create a GitHub App for authentication, deploy a runner scale set that scales to zero, and run your first self-hosted workflow job — verified against the official GitHub documentation.
Before you begin
- A running Kubernetes cluster (EKS, GKE, AKS, or local with kind/k3s)
- kubectl configured with cluster-admin access
- Helm 3.8+ installed (required for OCI chart support)
- A GitHub organization with admin access
This tutorial sets up Actions Runner Controller (ARC) v2 — the official GitHub-maintained solution for running self-hosted GitHub Actions runners on Kubernetes. By the end, you'll have a runner scale set that creates a fresh pod per job and scales to zero when idle.
ARC v2 is a complete rewrite from the community-maintained v1 (summerwind/actions-runner-controller). Key difference: v2 uses GitHub's runner scale set API with just-in-time runner tokens and long-polling — no inbound webhook server required.
Step 1: Create a GitHub App
ARC authenticates to GitHub using a GitHub App. This is scoped, auditable, and doesn't expire unlike a Personal Access Token.
Navigate to your GitHub organization: Settings → Developer settings → GitHub Apps → New GitHub App
Fill in:
- GitHub App name:
arc-runner-controller(or any unique name) - Homepage URL: your org URL, e.g.
https://github.com/your-org - Webhook: uncheck Active — ARC uses long-polling, not webhooks
Under Permissions, set:
- Organization permissions → Self-hosted runners: Read and write
Leave all other permissions at their defaults (None).
Under Where can this GitHub App be installed, select Only on this account.
Click Create GitHub App.
Collect the App credentials
On the App settings page:
- Copy the App ID (shown at the top of the page, e.g.
123456) - Scroll to Private keys → click Generate a private key. This downloads a
.pemfile.
Install the App on your organization
Still on the App settings page: click Install App in the left sidebar → select your organization → click Install.
After installation, the browser URL will look like:
https://github.com/organizations/your-org/settings/installations/789012
Copy the number at the end — that's your Installation ID.
Verify you have three pieces of information:
- App ID (e.g.
123456) - Installation ID (e.g.
789012) - Private key
.pemfile
Step 2: Create Namespaces and the Auth Secret
ARC uses two namespaces by convention:
arc-systems— the controller managerarc-runners— the runner scale set and runner pods
kubectl create namespace arc-systems
kubectl create namespace arc-runnersCreate the GitHub App secret in the runner namespace:
kubectl create secret generic arc-github-secret \
--namespace arc-runners \
--from-literal=github_app_id="123456" \
--from-literal=github_app_installation_id="789012" \
--from-file=github_app_private_key=./your-app-name.YYYY-MM-DD.private-key.pemReplace 123456, 789012, and the .pem filename with your actual values.
Verify the secret was created with the correct keys:
kubectl get secret arc-github-secret -n arc-runners -o jsonpath='{.data}' | \
python3 -c "import sys,json; d=json.load(sys.stdin); print('\n'.join(d.keys()))"Expected output:
github_app_id
github_app_installation_id
github_app_private_key
Step 3: Install the ARC Controller Manager
The controller manager is a cluster-wide component that watches AutoscalingRunnerSet resources and manages AutoscalingListener pods.
First, check the latest chart version:
helm show chart \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller \
| grep ^versionInstall the controller:
helm install arc \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller \
--namespace arc-systems \
--create-namespaceWait for the controller to be ready:
kubectl wait --for=condition=Ready pods \
--all -n arc-systems \
--timeout=120sVerify:
kubectl get pods -n arc-systemsExpected output:
NAME READY STATUS RESTARTS
arc-gha-runner-scale-set-controller-xxxxxxxxx-xxxxx 1/1 Running 0
Step 4: Install a Runner Scale Set
Create a values file for the runner scale set:
1# arc-runner-values.yaml
2githubConfigUrl: "https://github.com/your-org"
3githubConfigSecret: arc-github-secret
4
5runnerScaleSetName: "k8s-runners"
6
7minRunners: 0 # scale to zero when no jobs queued
8maxRunners: 10 # cap on concurrent runner pods
9
10containerMode:
11 type: "kubernetes"
12 kubernetesModeWorkVolumeClaim:
13 accessModes: ["ReadWriteOnce"]
14 storageClassName: "standard" # use your cluster's StorageClass name
15 resources:
16 requests:
17 storage: 1Gi
18
19template:
20 spec:
21 containers:
22 - name: runner
23 image: ghcr.io/actions/actions-runner:latest
24 resources:
25 requests:
26 cpu: 500m
27 memory: 512Mi
28 limits:
29 cpu: 2
30 memory: 2GiReplace your-org with your GitHub organization name and standard with your cluster's StorageClass:
# Check available StorageClasses
kubectl get storageclass
# On EKS: typically "gp2" or "gp3"
# On GKE: "standard" or "premium-rwo"
# On kind/k3s: "standard"Install the runner scale set:
helm install arc-runner-set \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
--namespace arc-runners \
--create-namespace \
--values arc-runner-values.yamlCheck the scale set status:
helm list -n arc-runnersExpected output:
NAME NAMESPACE STATUS CHART
arc-runner-set arc-runners deployed gha-runner-scale-set-x.x.x
Check for the listener pod — this pod maintains the long-poll connection to GitHub:
kubectl get pods -n arc-runnersExpected output (one listener pod, no runner pods yet since minRunners: 0):
NAME READY STATUS
arc-runner-set-xxxxxxxxxx-listener 1/1 Running
Step 5: Run Your First Self-Hosted Workflow
Create a test workflow in any repository in your GitHub organization. The runs-on value must exactly match runnerScaleSetName from your values file (k8s-runners).
1# .github/workflows/test-arc.yml
2name: Test ARC Runner
3
4on:
5 workflow_dispatch: # manual trigger for testing
6
7jobs:
8 test:
9 runs-on: k8s-runners
10 steps:
11 - name: Print runner info
12 run: |
13 echo "Hostname: $(hostname)"
14 echo "Kernel: $(uname -r)"
15 echo "CPU cores: $(nproc)"
16 echo "Memory: $(free -h | awk '/^Mem:/ {print $2}')"
17
18 - name: Verify Kubernetes environment
19 run: |
20 echo "Running in pod: $HOSTNAME"
21 cat /etc/os-release | grep PRETTY_NAMECommit and push, then trigger the workflow manually: Actions tab → Test ARC Runner → Run workflow.
Watch the runner pod appear
In a separate terminal, watch the runner namespace:
kubectl get pods -n arc-runners -wWhen the workflow job is picked up, you'll see a runner pod appear:
NAME READY STATUS
arc-runner-set-xxxxxxxxxx-listener 1/1 Running
arc-runner-set-xxxxxxxxxx-runner-xxxx 0/1 Pending
arc-runner-set-xxxxxxxxxx-runner-xxxx 0/1 ContainerCreating
arc-runner-set-xxxxxxxxxx-runner-xxxx 1/1 Running
arc-runner-set-xxxxxxxxxx-runner-xxxx 0/1 Completed
arc-runner-set-xxxxxxxxxx-runner-xxxx 0/0 Terminating
The pod appears when the job is queued, runs the job, and terminates when complete. This is the ephemeral model — each job gets a fresh pod.
Step 6: Verify Scale to Zero
With minRunners: 0, there should be no runner pods running when no jobs are queued:
kubectl get pods -n arc-runnersExpected: only the listener pod, no runner pods.
Check the scale set's current state:
kubectl get autoscalingrunnerset -n arc-runnersThe output columns depend on the printer columns defined by the installed CRD version. You'll see at minimum the NAME and AGE columns, and newer ARC versions include runner counts. What matters is confirming the resource exists and there are no error conditions:
kubectl describe autoscalingrunnerset arc-runner-set -n arc-runnersLook at the Status: block at the bottom of the output. After all jobs have completed and the scale set has stabilized, the runner count fields should show zero running runners. If they do, scale-to-zero is working.
Step 7: Custom Runner Image (Optional)
The default runner image is minimal. For workflows that need kubectl, helm, or awscli pre-installed, build a custom image:
1# Dockerfile.runner
2FROM ghcr.io/actions/actions-runner:latest
3
4USER root
5
6# Add curl and unzip for downloading tools
7RUN apt-get update && apt-get install -y --no-install-recommends \
8 curl unzip \
9 && rm -rf /var/lib/apt/lists/*
10
11# kubectl
12RUN KUBECTL_VERSION=$(curl -sL https://dl.k8s.io/release/stable.txt) && \
13 curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" && \
14 install -m 0755 kubectl /usr/local/bin/kubectl && \
15 rm kubectl
16
17# helm
18RUN curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
19
20# AWS CLI v2
21RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
22 && unzip /tmp/awscliv2.zip -d /tmp \
23 && /tmp/aws/install \
24 && rm -rf /tmp/aws /tmp/awscliv2.zip
25
26USER runnerBuild and push:
docker build -f Dockerfile.runner -t your-registry/arc-runner:1.0.0 .
docker push your-registry/arc-runner:1.0.0Update arc-runner-values.yaml to use the custom image:
1template:
2 spec:
3 containers:
4 - name: runner
5 image: your-registry/arc-runner:1.0.0
6 resources:
7 requests:
8 cpu: 500m
9 memory: 512MiUpgrade the scale set:
helm upgrade arc-runner-set \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
--namespace arc-runners \
--values arc-runner-values.yamlTest by adding a step to your workflow that uses one of the pre-installed tools:
- name: Verify kubectl is available
run: kubectl version --clientTroubleshooting
Runner pod stuck in Pending
List all pods in the runner namespace and describe the stuck one:
kubectl get pods -n arc-runners
# Identify the runner pod (not the listener) that is stuck, then:
kubectl describe pod <runner-pod-name> -n arc-runnersLook at the Events section at the bottom. Common causes:
Insufficient cpuorInsufficient memory— increase node capacity or lower pod resource requestsPersistentVolumeClaim ... not found— the StorageClass name in values doesn't exist; checkkubectl get storageclass
Workflow job stays queued, no runner pod appears
Check the listener pod logs. List all pods in the runner namespace — the listener pod is the one that persists between jobs (runner pods are ephemeral):
kubectl get pods -n arc-runners
# The listener pod name contains "-listener" and stays Running between jobs
kubectl logs -n arc-runners <listener-pod-name> --tail=50If you see authentication errors, verify the secret keys match exactly and the GitHub App is installed on the correct organization.
Runner pod appears but workflow fails immediately
Check the runner pod logs:
kubectl get pods -n arc-runners
# Identify the runner pod (short-lived, not the listener), then:
kubectl logs -n arc-runners <runner-pod-name>A failed to connect to GitHub error usually means the JIT token has expired because the pod took too long to start. Reduce pod startup time by pre-pulling the runner image on your nodes or using a lighter base image.
What's Next
- IRSA on EKS: annotate the runner pod's service account with an IAM role ARN so workflows can access AWS without stored credentials — see Actions Runner Controller on Kubernetes
- Multi-team isolation: install separate runner scale sets in separate namespaces with different
runnerScaleSetNamevalues and scoped service accounts - DinD mode: switch to
containerMode.type: "dind"for workflows that needdocker buildnatively — but isolate these runners on dedicated nodes with aNoScheduletaint - CI/CD pipeline: combine ARC runners with the OIDC → ECR → EKS deployment pipeline from GitHub Actions CI/CD for EKS
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.