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
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
kubectlaccess - 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:
| Method | Pros | Cons |
|---|---|---|
| Fine-grained PAT | Simple setup, scoped to repos | Tied to a user, expires, lower rate limits |
| GitHub App | Not user-tied, higher rate limits, granular permissions | Slightly 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:
- App configuration:
- Name:
arc-runners(must be globally unique on GitHub) - Homepage URL: anything
- Webhook: uncheck “Active” (ARC v2 doesn’t use webhooks)
- Name:
- Permissions (Repository permissions only):
- Administration: Read and write
- Metadata: Read-only
- 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 (
.pemfile) — 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:
-
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 -
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
- ARC runners are minimal — they’re not GitHub-hosted runners. Plan for missing tools.
- GitHub App > PAT — especially for teams. Not tied to any individual user.
- Repo admin is enough — you don’t need org admin to set up per-repo runners.
- Scale to zero works great — no cost when there are no CI jobs running.
- One controller, many scale sets — the controller is shared, runner scale sets are per-repo (or per-use-case).
- 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.