Scaling GitHub Actions Runners on Kubernetes with Actions Runner Controller
A comprehensive guide to deploying ephemeral self-hosted GitHub Actions runners using Actions Runner Controller ARC, AutoScalingRunnerSets, and rootless container isolation.
Running continuous integration and deployment pipelines on GitHub-hosted runners offers simplicity, but teams often hit walls around compute constraints, concurrency limits, and mounting costs. For organizations running Docker builds, heavy integration suites, or workloads requiring private VPC access, self-hosted runners become necessary.
However, traditional self-hosted runners hosted on static virtual machines bring severe operational headaches:
- State Leakage: Previous jobs leave artifacts, Docker layers, and credentials on disk, leading to unpredictable pipeline failures.
- Security Risks: If an untrusted pull request runs on a persistent VM runner, it can access residual environment secrets or compromise the host.
- Inefficient Capacity Utilization: VM runners sit idle overnight while racking up cloud bills, yet queue times spike during working hours when developer activity peaks.
The solution is ephemeral runners running on Kubernetes, managed by Actions Runner Controller (ARC).
Here is an architectural walkthrough of the modern ARC controller architecture and a guide to deploying secure, ephemeral runner scale sets.
Architectural Evolution of Actions Runner Controller
Early iterations of ARC used custom webhook listeners and custom resource definitions like RunnerDeployment and RunnerReplicaSet. While functional, they suffered from synchronization delays, race conditions when jobs completed, and fragile webhook routing.
The modern architecture redesigns ARC to use GitHub native runner scale sets (gha-runner-scale-set).
Instead of managing runners through ad-hoc webhooks, ARC establishes a persistent outbound long-poll connection directly to the GitHub Actions Service via an autoscaling listener.
flowchart TD
subgraph GitHubCloud["GitHub Platform"]
GHA["GitHub Actions Workflow Queue"]
end
subgraph KubernetesCluster["Kubernetes Cluster"]
subgraph ARCSystem["arc-systems Namespace"]
ARC_Controller["ARC Controller Pod"]
end
subgraph RunnerNamespace["arc-runners Namespace"]
Listener["AutoScaling Listener Pod (Outbound HTTPS Long Poll)"]
Pod1["Ephemeral Runner Pod 1 (Active Job)"]
Pod2["Ephemeral Runner Pod 2 (Active Job)"]
end
end
Listener -->|Outbound HTTPS Long Poll| GHA
GHA -->|Job Dispatched| Listener
Listener -->|Request Pod Creation| ARC_Controller
ARC_Controller -->|Spawn Ephemeral Pod| Pod1
ARC_Controller -->|Spawn Ephemeral Pod| Pod2
Pod1 -->|Execute & Terminate| GHA
Key Advantages of the Listener Model
- Zero Inbound Ingress: The listener connects outward over standard HTTPS (port 443). You do not need to open public firewall ports, manage load balancers, or expose public webhooks to GitHub.
- Accurate Queue Depth: The listener reads the exact pending job demand directly from GitHub Actions, scaling pods up immediately when workflows trigger and down to zero when queues empty.
- Guaranteed Ephemerality: Each runner pod executes exactly one job and terminates immediately. The pod storage is discarded, ensuring zero cross-job pollution.
Deploying ARC with Helm
ARC is deployed in two tiers: the controller operator in a system namespace, and one or more AutoScalingRunnerSet instances in dedicated runner namespaces.
Step 1: Deploy the ARC Controller Manager
First, install the core controller into arc-systems:
1
2
3
4
helm install arc \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller \
--namespace arc-systems \
--create-namespace
Verify that the controller manager pod is in a ready state:
1
kubectl get pods -n arc-systems
Step 2: Configure Authentication
ARC authenticates to GitHub using either a GitHub App or a Personal Access Token. A GitHub App is the recommended standard because it provides finer-grained repository permissions and higher API rate limits.
Create a Kubernetes secret containing your GitHub App private key and credentials:
1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Secret
metadata:
name: github-app-secret
namespace: arc-runners
type: Opaque
stringData:
github_app_id: "123456"
github_app_installation_id: "7891011"
github_app_private_key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA0m...
-----END RSA PRIVATE KEY-----
Configuring the AutoScalingRunnerSet
The AutoScalingRunnerSet defines the runner image, container specifications, resource limits, and auto-scaling boundaries.
Here is a production-grade configuration that scales from 0 to 30 runners on demand:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
apiVersion: actions.github.com/v1alpha1
kind: AutoscalingRunnerSet
metadata:
name: k8s-ephemeral-runner
namespace: arc-runners
spec:
githubConfigUrl: "https://github.com/my-org-or-repo"
githubConfigSecret: github-app-secret
minRunners: 0
maxRunners: 30
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
command: ["/home/runner/run.sh"]
env:
- name: ACTIONS_RUNNER_HOOK_JOB_STARTED
value: "/etc/arc/hooks/job-started.sh"
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "4"
memory: "8Gi"
securityContext:
readOnlyRootFilesystem: false
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumeMounts:
- name: work
mountPath: /home/runner/_work
- name: tmp
mountPath: /tmp
volumes:
- name: work
emptyDir: {}
- name: tmp
emptyDir: {}
Solving the Docker-in-Docker Challenge
Most CI pipelines build container images. When running inside Kubernetes runner pods, workflows require a mechanism to interact with a Docker daemon.
Three common patterns exist, each with distinct security profiles:
1. Docker-in-Docker (dind) Sidecar
A secondary container running docker:dind runs alongside the runner container in the same pod.
- Pros: Full compatibility with standard
docker build,docker-compose, and service containers. - Cons: Requires running the sidecar with
securityContext.privileged: true. - Mitigation: Run the sidecar in an isolated node pool or namespace with restricted Kubernetes RBAC.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Pod spec snippet for dind sidecar
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
env:
- name: DOCKER_HOST
value: tcp://localhost:2376
- name: DOCKER_TLS_VERIFY
value: "1"
- name: DOCKER_CERT_PATH
value: /certs/client
volumeMounts:
- name: dind-certs
mountPath: /certs/client
readOnly: true
- name: dind
image: docker:dind
securityContext:
privileged: true
volumeMounts:
- name: dind-certs
mountPath: /certs/client
2. Kubernetes-Mode Runner (Container Hooks)
Rather than running Docker commands inside the runner container, ARC container hooks delegate step containers directly to the Kubernetes API, launching sibling pods on the cluster for each workflow step.
- Pros: No privileged daemon required; native Kubernetes pod isolation.
- Cons: Some third-party GitHub Actions that assume a local Docker socket will fail or require modification.
3. Daemonless Builders (Kaniko or Buildah)
Pipelines replace docker build with daemonless tools like Kaniko or Buildah.
- Pros: Completely unprivileged builds without Docker daemon dependencies.
- Cons: Workflows must be updated to use CLI alternatives rather than standard Docker commands.
Target Workflow Usage
Once the runner scale set is registered, triggering it from GitHub Actions requires specifying the runner set name in runs-on:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
name: Production Test Suite
on:
pull_request:
branches: [main]
jobs:
integration-test:
runs-on: k8s-ephemeral-runner
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Runtime
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install and Test
run: |
npm ci
npm test
When this pull request is opened:
- GitHub registers a queued job targeting
k8s-ephemeral-runner. - The ARC listener detects the demand and requests a new runner pod.
- Kubernetes schedules the pod on an available node.
- The pod executes the checkout and test commands.
- Upon job completion, the pod terminates and deletes its temporary storage.
Production Best Practices
- Set
minRunners: 0for Non-Critical Queues: Scaling to zero saves compute costs during off-hours. For high-priority pipelines where waiting 30 seconds for pod startup is unacceptable, maintain a warm pool of 1 to 2 idle runners withminRunners: 2. - Combine with Dynamic Node Autoscaling: Pair ARC with Karpenter. When a burst of 20 PRs triggers simultaneously, ARC spawns 20 runner pods, which prompts Karpenter to rapidly provision dedicated spot instances. Once finished, pods terminate, nodes consolidate, and costs drop back to baseline.
- Enforce Ephemeral Volumes: Always use
emptyDirbacked by memory or local SSDs for/home/runner/_workand/tmp. This guarantees that high I/O test suites run fast and prevents disk exhaustion on host nodes. - Monitor Queue Metrics: Scrape ARC Prometheus metrics (
gha_runner_scale_set_running_jobsandgha_runner_scale_set_queued_jobs) to identify runner pool saturation and track CI wait times across engineering teams.
Summary
Migrating from static virtual machine runners to ephemeral Actions Runner Controller scale sets eliminates state contamination, secures CI workflows against cross-run tampering, and cuts idle cloud infrastructure spend to zero.