Part ofGitOps with Kubernetes·Step 3 of 3
DevOps & Platform

Setting Up Actions Runner Controller (ARC) v2 on Kubernetes

Intermediate60 min to complete18 min read

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
GitHub Actions
Kubernetes
ARC
CI/CD
Self-hosted runners
Platform Engineering

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:

  1. Copy the App ID (shown at the top of the page, e.g. 123456)
  2. Scroll to Private keys → click Generate a private key. This downloads a .pem file.

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 .pem file

Step 2: Create Namespaces and the Auth Secret

ARC uses two namespaces by convention:

  • arc-systems — the controller manager
  • arc-runners — the runner scale set and runner pods
bash
kubectl create namespace arc-systems
kubectl create namespace arc-runners

Create the GitHub App secret in the runner namespace:

bash
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.pem

Replace 123456, 789012, and the .pem filename with your actual values.

Verify the secret was created with the correct keys:

bash
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:

bash
helm show chart \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller \
  | grep ^version

Install the controller:

bash
helm install arc \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller \
  --namespace arc-systems \
  --create-namespace

Wait for the controller to be ready:

bash
kubectl wait --for=condition=Ready pods \
  --all -n arc-systems \
  --timeout=120s

Verify:

bash
kubectl get pods -n arc-systems

Expected 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:

yaml
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: 2Gi

Replace your-org with your GitHub organization name and standard with your cluster's StorageClass:

bash
# 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:

bash
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.yaml

Check the scale set status:

bash
helm list -n arc-runners

Expected 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:

bash
kubectl get pods -n arc-runners

Expected 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).

yaml
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_NAME

Commit 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:

bash
kubectl get pods -n arc-runners -w

When 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:

bash
kubectl get pods -n arc-runners

Expected: only the listener pod, no runner pods.

Check the scale set's current state:

bash
kubectl get autoscalingrunnerset -n arc-runners

The 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:

bash
kubectl describe autoscalingrunnerset arc-runner-set -n arc-runners

Look 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:

dockerfile
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 runner

Build and push:

bash
docker build -f Dockerfile.runner -t your-registry/arc-runner:1.0.0 .
docker push your-registry/arc-runner:1.0.0

Update arc-runner-values.yaml to use the custom image:

yaml
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: 512Mi

Upgrade the scale set:

bash
helm upgrade arc-runner-set \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
  --namespace arc-runners \
  --values arc-runner-values.yaml

Test by adding a step to your workflow that uses one of the pre-installed tools:

yaml
- name: Verify kubectl is available
  run: kubectl version --client

Troubleshooting

Runner pod stuck in Pending

List all pods in the runner namespace and describe the stuck one:

bash
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-runners

Look at the Events section at the bottom. Common causes:

  • Insufficient cpu or Insufficient memory — increase node capacity or lower pod resource requests
  • PersistentVolumeClaim ... not found — the StorageClass name in values doesn't exist; check kubectl 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):

bash
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=50

If 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:

bash
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 runnerScaleSetName values and scoped service accounts
  • DinD mode: switch to containerMode.type: "dind" for workflows that need docker build natively — but isolate these runners on dedicated nodes with a NoSchedule taint
  • 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.