Skip to content

Self-Hosted GitHub Actions Runners on EKS with Actions Runner Controller (ARC)

• 4 min read • Vamsi Krishna Sethu
Table of Contents
  1. Why ARC?
  2. Architecture
  3. Prerequisites
  4. Authentication: GitHub App vs PAT
  5. Getting the GitHub App Set Up
  6. Installation
  7. Step 1: Install the ARC Controller
  8. Step 2: Create the GitHub App Secret
  9. Step 3: Deploy Runner Scale Sets (one per repo)
  10. Step 4: Verify
  11. Step 5: Use in Workflows
  12. Problems We Hit
  13. Problem 1: Docker actions fail — “no such file or directory: /var/run/docker.sock”
  14. Problem 2: “pip: command not found”
  15. Problem 3: “gh: command not found”
  16. The Real Fix: Custom Runner Image
  17. Key Takeaways
  18. Cost

We needed self-hosted GitHub Actions runners for our CI/CD pipelines. The goals were simple — save on GitHub-hosted runner costs, run jobs inside our own AWS network, and auto-scale from zero when idle. This post covers the full setup of ARC v2 on an existing EKS cluster, the problems we ran into, and how we solved them.

Why ARC?

GitHub’s hosted runners are convenient, but they come with limitations:

  • Cost — they get expensive at scale (especially for private repos)
  • Network — they can’t access resources inside your VPC without workarounds
  • Customization — you’re stuck with their pre-baked images

Actions Runner Controller (ARC) v2 solves all of this. It runs ephemeral runners as Kubernetes pods, scales to zero when there are no jobs, and each runner is a fresh pod — no state leakage between jobs.

Architecture

graph TB
  subgraph EKS["EKS Cluster (ap-south-1)"]
      subgraph NS1["Namespace: arc-systems"]
          CTRL["ARC Controller(gha-runner-scale-set-controller)"]
      end
      subgraph NS2["Namespace: arc-runners"]
          RS1["Runner Scale Set repo-1"]
          RS2["Runner Scale Set repo-2"]
          RS3["Runner Scale Set repo-3"]
          RS4["Runner Scale Set repo-4"]
      end
  end

  GH["GitHub.com Jobs API"]

  CTRL -->|manages| RS1
  CTRL -->|manages| RS2
  CTRL -->|manages| RS3
  CTRL -->|manages| RS4
  CTRL <-->|HTTPS polls for jobs| GH

  RS1 -.->|scales 0 to 5| POD1["Ephemeral Runner Pod"]
  RS2 -.->|scales 0 to 5| POD2["Ephemeral Runner Pod"]

The controller watches for pending workflow jobs via the GitHub API. When a job targets one of our runner labels, it spins up an ephemeral pod to handle it. Once the job completes, the pod is destroyed.

Prerequisites

  • An existing EKS cluster with kubectl access
  • Helm 3.x
  • A GitHub App (or PAT) with appropriate permissions

Authentication: GitHub App vs PAT

Since I’m a repo admin (not org admin), I can’t set up org-level runners. The authentication options are:

MethodProsCons
Fine-grained PATSimple setup, scoped to reposTied to a user, expires, lower rate limits
GitHub AppNot user-tied, higher rate limits, granular permissionsSlightly more setup

I went with a GitHub App — it’s the recommended approach and survives team member changes.

Getting the GitHub App Set Up

Since I’m a repo admin and not an org admin, I couldn’t create the GitHub App at the org level myself. I asked our org administrator to create the app with these settings:

  1. App configuration:
    • Name: arc-runners (must be globally unique on GitHub)
    • Homepage URL: anything
    • Webhook: uncheck “Active” (ARC v2 doesn’t use webhooks)
  2. Permissions (Repository permissions only):
    • Administration: Read and write
    • Metadata: Read-only
  3. Installation: installed on the org, scoped to our 4 repos

What I received from the org admin:

  • App ID — from the app’s settings page
  • Installation ID — from the URL under Settings → Installations
  • Private Key (.pem file) — generated on the app settings page

Installation

Step 1: Install the ARC Controller

One controller per cluster — it manages all runner scale sets:

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

Step 2: Create the GitHub App Secret

kubectl create namespace arc-runners

kubectl create secret generic github-app-secret \
  --namespace arc-runners \
  --from-literal=github_app_id="<APP_ID>" \
  --from-literal=github_app_installation_id="<INSTALLATION_ID>" \
  --from-file=github_app_private_key="path/to/private-key.pem"

Step 3: Deploy Runner Scale Sets (one per repo)

for REPO in repo-1 repo-2 repo-3 repo-4; do
  helm install "arc-runner-${REPO}" \
    --namespace arc-runners \
    oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
    --set githubConfigUrl="https://github.com/<org>/${REPO}" \
    --set githubConfigSecret=github-app-secret \
    --set runnerScaleSetName="arc-runner-${REPO}" \
    --set minRunners=0 \
    --set maxRunners=5
done

Step 4: Verify

kubectl get pods -n arc-systems   # Controller should be running
kubectl get pods -n arc-runners   # Empty until a job is queued

Step 5: Use in Workflows

jobs:
  build:
    runs-on: arc-runner-repo-1
    steps:
      - uses: actions/checkout@v4
      # ...

Problems We Hit

Problem 1: Docker actions fail — “no such file or directory: /var/run/docker.sock”

Symptom: Any action that uses runs: using: 'docker' (like bridgecrewio/checkov-action) fails because there’s no Docker daemon in the runner pod.

Root cause: The default ARC runner image doesn’t include Docker. Unlike GitHub-hosted runners that come with everything pre-installed, ARC runners are minimal.

Solutions:

  1. Enable Docker-in-Docker on the scale set (if you need Docker):

    helm upgrade arc-runner-<repo> \
      --namespace arc-runners \
      oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
      --set containerMode.type=dind \
      --reuse-values
  2. Avoid Docker-based actions — run tools natively instead:

    # Instead of the Docker-based checkov-action:
    - uses: actions/setup-python@v5
      with:
        python-version: '3.11'
    - run: |
        pip install checkov
        checkov -d . --framework terraform --quiet --compact

Problem 2: “pip: command not found”

Symptom: Trying to pip install anything fails.

Fix: Use actions/setup-python first — the ARC runner image doesn’t have Python in PATH by default:

- uses: actions/setup-python@v5
  with:
    python-version: '3.11'
- run: pip install checkov

Problem 3: “gh: command not found”

Symptom: Any step using the GitHub CLI fails.

Fix: Install it in the workflow or use cli/cli-action:

- name: Install GitHub CLI
  run: |
    curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
      | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \
      https://cli.github.com/packages stable main" \
      | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
    sudo apt-get update && sudo apt-get install -y gh

The Real Fix: Custom Runner Image

If you keep hitting missing tools, build a custom image with everything baked in:

FROM ghcr.io/actions/actions-runner:latest

RUN sudo apt-get update && sudo apt-get install -y \
    gh \
    python3 \
    python3-pip \
    jq \
    curl \
    unzip \
    && sudo rm -rf /var/lib/apt/lists/*

Build, push to ECR/GHCR, and reference it in your Helm values:

template:
  spec:
    containers:
      - name: runner
        image: <your-registry>/arc-runner-custom:latest

This installs tools once in the image instead of on every job run.

Key Takeaways

  1. ARC runners are minimal — they’re not GitHub-hosted runners. Plan for missing tools.
  2. GitHub App > PAT — especially for teams. Not tied to any individual user.
  3. Repo admin is enough — you don’t need org admin to set up per-repo runners.
  4. Scale to zero works great — no cost when there are no CI jobs running.
  5. One controller, many scale sets — the controller is shared, runner scale sets are per-repo (or per-use-case).
  6. Custom images save time — if your jobs need Docker, gh, Python, etc., bake them into the image.

Cost

Running this on an existing cluster, the incremental cost is essentially just the compute used when jobs are running. With minRunners: 0, idle cost is zero (runner pods). The ARC controller itself is lightweight (~128MB RAM).

Compared to GitHub-hosted runners at $0.008/min for Linux, self-hosted runners on a m5.large (~$0.096/hr in ap-south-1) break even quickly if you’re running more than ~12 minutes of CI per hour on average.