GitHub Actions Self-Hosted Runners on Kubernetes
GitHub Actions self-hosted runners on Kubernetes give you the flexibility of self-managed CI/CD infrastructure with the convenience of GitHub Actions workflows. Instead of paying per-minute for GitHub-hosted runners or waiting in queues, you run your own runners on your Kubernetes cluster with auto-scaling, custom tooling, and full control over the execution environment.
This guide covers the complete setup using Actions Runner Controller (ARC) v2, the officially supported Kubernetes operator. You will learn how to deploy, scale, secure, and optimize self-hosted runners for production workloads.
Why Self-Hosted Runners?
GitHub-hosted runners work well for simple workflows, but they have limitations that matter at scale:
GitHub-Hosted vs Self-Hosted Runners
┌────────────────────────┬───────────────┬───────────────────┐
│ Factor │ GitHub-Hosted │ Self-Hosted (K8s)│
├────────────────────────┼───────────────┼───────────────────┤
│ Cost (1000 min/month) │ ~$40-80 │ ~$10-20 (infra) │
│ Startup Time │ 30-90s │ 5-15s │
│ Custom Tools │ Limited │ Full control │
│ Network Access │ Public only │ Private VPC │
│ GPU Support │ Limited │ Full NVIDIA/AMD │
│ Cache Persistence │ Limited │ PVC-backed │
│ Concurrent Jobs │ Quota-limited │ Cluster capacity │
│ Security │ Ephemeral │ Configurable │
└────────────────────────┴───────────────┴───────────────────┘
Installing Actions Runner Controller (ARC)
# Install ARC v2 using Helm
helm repo add actions-runner-controller \
https://actions-runner-controller.github.io/actions-runner-controller
helm install arc \
--namespace arc-system \
--create-namespace \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
# Create a GitHub App for authentication (recommended over PAT)
# Go to: GitHub Org Settings → Developer Settings → GitHub Apps
# Permissions needed:
# - Organization: Self-hosted runners (Read & Write)
# - Repository: Actions (Read), Metadata (Read)
# Create Kubernetes secret with GitHub App credentials
kubectl create secret generic github-app-secret \
--namespace arc-runners \
--from-literal=github_app_id=123456 \
--from-literal=github_app_installation_id=78901234 \
--from-file=github_app_private_key=./private-key.pem
Deploying Runner Scale Sets
# runner-scale-set.yaml
apiVersion: actions.github.com/v1alpha1
kind: AutoscalingRunnerSet
metadata:
name: k8s-runners
namespace: arc-runners
spec:
githubConfigUrl: "https://github.com/myorg"
githubConfigSecret: github-app-secret
minRunners: 2 # Always keep 2 warm runners
maxRunners: 20 # Scale up to 20 during peak
runnerGroup: "kubernetes"
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "4"
memory: "8Gi"
volumeMounts:
- name: work
mountPath: /home/runner/_work
- name: docker-socket
mountPath: /var/run/docker.sock
# Docker-in-Docker sidecar for container builds
- name: dind
image: docker:dind
securityContext:
privileged: true
volumeMounts:
- name: work
mountPath: /home/runner/_work
- name: docker-socket
mountPath: /var/run/docker.sock
volumes:
- name: work
emptyDir: {}
- name: docker-socket
emptyDir: {}
# Node affinity for dedicated CI nodes
nodeSelector:
workload-type: ci-runner
tolerations:
- key: "ci-runner"
operator: "Exists"
effect: "NoSchedule"
# Deploy the runner scale set
helm install k8s-runners \
--namespace arc-runners \
--create-namespace \
-f runner-scale-set.yaml \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
Custom Runner Images
# Dockerfile for custom runner with project-specific tools
FROM ghcr.io/actions/actions-runner:latest
# Install build tools
RUN sudo apt-get update && sudo apt-get install -y \
build-essential \
openjdk-21-jdk \
maven \
gradle \
nodejs \
npm \
docker-compose-plugin \
&& sudo rm -rf /var/lib/apt/lists/*
# Pre-cache common dependencies
COPY gradle-cache/ /home/runner/.gradle/
COPY maven-cache/ /home/runner/.m2/
# Install kubectl and helm for deployment workflows
RUN curl -LO "https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl" \
&& sudo install kubectl /usr/local/bin/ \
&& curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
Security Hardening
# Pod security for runners
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: runner
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]
Using in Workflows
# .github/workflows/build.yml
name: Build & Deploy
on: [push]
jobs:
build:
runs-on: k8s-runners # Matches the runner scale set name
steps:
- uses: actions/checkout@v4
- name: Build
run: ./gradlew build
- name: Test
run: ./gradlew test
- name: Deploy
run: kubectl apply -f k8s/
When NOT to Self-Host
Self-hosted runners add operational overhead. Therefore, stick with GitHub-hosted runners when: your team is small (under 10 devs), you don’t need private network access, your workflows are simple and infrequent, or you lack Kubernetes expertise. The cost savings only justify the complexity above ~2000 CI minutes per month.
Key Takeaways
GitHub Actions self-hosted runners on Kubernetes provide faster builds, lower costs, and full environment control. ARC v2 makes deployment straightforward with auto-scaling. As a result, consider self-hosting when GitHub-hosted runner limitations become a bottleneck for your team’s velocity.
Related Reading
- GitHub Actions CI/CD Pipeline Automation
- GitHub Actions Reusable Workflows
- Kubernetes Cost Optimization