GitHub Actions Self-Hosted Runners on Kubernetes: Complete Setup Guide

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     │
└────────────────────────┴───────────────┴───────────────────┘
GitHub Actions self-hosted runners Kubernetes infrastructure
Self-hosted runners on Kubernetes with auto-scaling and custom images

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
Kubernetes cluster running CI/CD runners
Runner pods auto-scaling based on queued GitHub Actions jobs

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/
CI/CD pipeline monitoring dashboard
Monitoring runner utilization and workflow performance

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

External Resources

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top