Skip to content

Repository files navigation

gha-stateful-runner

A Helm chart for deploying stateful GitHub Actions self-hosted runners on Kubernetes using a UI token — no Personal Access Token (PAT) or Github App Installation required.

Each runner pod keeps its registration credentials in a dedicated PersistentVolumeClaim, so runners survive restarts without re-registering on every startup.

Prerequisites

  • Kubernetes 1.25+
  • Helm 3.x
  • A GitHub Actions runner registration token (obtained from GitHub → Repository → Settings → Actions → Runners → New self-hosted runner)

Quick Start

  1. In GitHub, open your repository and go to Settings -> Actions -> Runners.
  2. Click New self-hosted runner.
  3. Copy the runner registration token shown in the setup page (this is the ui_token, valid for about 1 hour).
  4. Install the chart from GHCR with your repo URL and token:
helm install my-runners oci://ghcr.io/eric2788/charts/gha-stateful-runner \
  --set runner.repoUrl=https://github.com/your-org/your-repo \
  --set runner.token=YOUR_REGISTRATION_TOKEN

Note

  • runner.repoUrl and either runner.token or runner.secret are required.
  • Version is not pinned in these examples so new chart releases are picked up automatically.

Local Development Install

For local chart development/testing from a cloned repo:

helm install my-runners . \
  --set runner.repoUrl=https://github.com/your-org/your-repo \
  --set runner.token=YOUR_REGISTRATION_TOKEN

Configuration

Parameter Description Default
image Runner container image ghcr.io/actions/actions-runner
version Runner image tag 2.333.0
imagePullPolicy Image pull policy for runner/init/DinD containers IfNotPresent
fullnameOverride Override the full resource name ""
includeNamespace Include namespace in resource metadata false
namespaceOverride Override the namespace ""
runner.name Runner name prefix gha-sts-runner
runner.count Number of runner replicas 3
runner.terminationGracePeriodSeconds Pod termination grace period before force kill 600
runner.preStop.enabled Enable bounded preStop drain logic true
runner.preStop.maxWaitSeconds Max wait for in-flight runner work during preStop 540
runner.preStop.pollIntervalSeconds Poll interval used by preStop wait loop 5
runner.repoUrl Required. GitHub repo or org URL ""
runner.token Runner registration token (required when runner.secret is not set) ""
runner.secret Name of an existing Secret with key ui_token ""
runner.labels Runner labels for job routing [self-hosted, linux, gha-static]
runner.extraEnv Additional environment variables for the runner container. []
runner.extraVolumeMounts Additional volume mounts to attach to the runner container (e.g. for caching). []
runner.storageClass StorageClass for credentials PVC "" (cluster default)
runner.credStorageSize Storage size for credentials PVC "64Mi"
runner.resources Resource requests/limits for runner container See values.yaml
runner.initResources Resource requests/limits for init container See values.yaml
serviceAccount.create Create a dedicated ServiceAccount resource. When false, the runner pod uses serviceAccount.name if set, otherwise the namespace default ServiceAccount true
serviceAccount.name ServiceAccount name for the runner pod to use (existing or chart-created) ""
serviceAccount.automountServiceAccountToken Whether to auto-mount the Kubernetes API token on the runner pod false
serviceAccount.annotations Annotations for the ServiceAccount (e.g. IRSA) {}
podDisruptionBudget.enabled Create a PodDisruptionBudget true
podDisruptionBudget.minAvailable Minimum available pods during disruptions 1
securityContext Pod-level security context {fsGroup: 1001}
containerSecurityContext Container-level security context non-root, drop ALL capabilities
podAntiAffinity.enabled Spread runners across nodes true
podAntiAffinity.type preferred (soft) or required (hard) preferred
affinity Additional affinity rules (e.g. nodeAffinity) {}
podAnnotations Annotations for runner pods (e.g. Prometheus) {}
extraVolumes Additional pod-level volumes shared by runner.extraVolumeMounts and dind.extraVolumeMounts. []
extraManifests Extra templated Kubernetes manifests to deploy with this chart []
dind.enable Enable Docker-in-Docker sidecar false
dind.image DinD container image docker:27-dind
dind.extraEnv Additional environment variables for the DinD container. []
dind.extraVolumeMounts Additional volume mounts to attach to the DinD container. []
dind.cachePersistence Persist the Docker layer cache (/var/lib/docker) across pod restarts via a per-pod PVC false
dind.storageClass StorageClass for the docker-storage PVC (only used when dind.cachePersistence is true) "" (cluster default)
dind.cacheSize Size of the docker-storage PVC (only used when dind.cachePersistence is true) "10Gi"
dind.pruneOnStop Run docker system prune -f in a preStop hook to automatically remove dangling images and build cache before each pod restart. Recommended when dind.cachePersistence is true. false
dind.pruneTimeoutSeconds Max seconds allowed for DinD preStop prune before it is terminated (0 disables timeout). 120
dind.resources Resource requests/limits for DinD container See values.yaml
workspace.enabled Enable persistent workspace PVC for /home/runner/_work false
workspace.storageClass StorageClass for workspace PVC "" (cluster default)
workspace.size Size of workspace PVC "10Gi"
workspace.accessModes Access modes for workspace PVC [ReadWriteOnce]
workspace.subPath Optional sub-path within the workspace volume to mount ""
workspace.subPathExpr Optional sub-path expression used directly as volumeMount.subPathExpr. Any referenced env vars (for example $(POD_NAME)) must be provided by user-defined container env. ""
workspace.annotations Annotations for the workspace PVC (VolumeClaimTemplate only) {}
workspace.labels Labels for the workspace PVC (VolumeClaimTemplate only) {}
workspace.existingClaim Name of an existing PVC to mount instead of creating a per-pod VolumeClaimTemplate ""

Using an Existing Secret

Pre-create a secret and reference it to avoid passing the token on the command line:

kubectl create secret generic my-runner-token \
  --from-literal=ui_token=YOUR_REGISTRATION_TOKEN

helm install my-runners oci://ghcr.io/eric2788/charts/gha-stateful-runner \
  --set runner.repoUrl=https://github.com/your-org/your-repo \
  --set runner.secret=my-runner-token

Docker-in-Docker (DinD)

Enable the DinD sidecar to run Docker commands inside jobs:

helm install my-runners oci://ghcr.io/eric2788/charts/gha-stateful-runner \
  --set runner.repoUrl=https://github.com/your-org/your-repo \
  --set runner.token=YOUR_REGISTRATION_TOKEN \
  --set dind.enable=true

Warning

DinD requires privileged: true. Ensure your cluster's PodSecurity policy or admission controller allows privileged containers.

Persistent Docker Layer Cache

By default, the DinD sidecar stores Docker image layers in an emptyDir volume that is discarded whenever the pod restarts. This means every restart triggers a full re-pull of all images, which is wasteful in CI/CD environments with frequent restarts (rolling updates, node drain, pod eviction, etc.).

Enable dind.cachePersistence to instead back /var/lib/docker with a per-pod PersistentVolumeClaim. The PVC survives pod restarts, so cached image layers are reused across runs:

helm install my-runners oci://ghcr.io/eric2788/charts/gha-stateful-runner \
  --set runner.repoUrl=https://github.com/your-org/your-repo \
  --set runner.token=YOUR_REGISTRATION_TOKEN \
  --set dind.enable=true \
  --set dind.cachePersistence=true \
  --set dind.cacheSize=20Gi

Specify a StorageClass if your cluster has multiple storage backends:

helm install my-runners oci://ghcr.io/eric2788/charts/gha-stateful-runner \
  --set runner.repoUrl=https://github.com/your-org/your-repo \
  --set runner.token=YOUR_REGISTRATION_TOKEN \
  --set dind.enable=true \
  --set dind.cachePersistence=true \
  --set dind.storageClass=fast-ssd \
  --set dind.cacheSize=20Gi

Warning

Docker does not automatically garbage-collect unused image layers. With cachePersistence enabled, /var/lib/docker will grow over time as new images are pulled or built and is only pruned when the pod is terminating (via preStop) if dind.pruneOnStop is enabled. If a pod runs for long periods without being restarted, /var/lib/docker can still grow significantly until the next termination/restart, so set dind.cacheSize generously (e.g. 50Gi) based on your expected image footprint.

Enable dind.pruneOnStop to have the chart inject a preStop lifecycle hook that automatically runs docker system prune -f whenever the pod is terminating (for example, during deletion or restart). This is not continuous garbage collection; cleanup only happens on pod termination events. Dangling images and accumulated build cache are removed, while tagged images are preserved for reuse. The hook is bounded by dind.pruneTimeoutSeconds so prune does not consume the entire shutdown window:

helm install my-runners oci://ghcr.io/eric2788/charts/gha-stateful-runner \
  --set runner.repoUrl=https://github.com/your-org/your-repo \
  --set runner.token=YOUR_REGISTRATION_TOKEN \
  --set dind.enable=true \
  --set dind.cachePersistence=true \
  --set dind.cacheSize=20Gi \
  --set dind.pruneOnStop=true \
  --set dind.pruneTimeoutSeconds=180

Note

When cachePersistence is disabled (the default), the DinD storage falls back to emptyDir and the behaviour is identical to previous chart versions.

Note

docker system prune can take a noticeable amount of time when the cache is large. Even with dind.pruneTimeoutSeconds, DinD prune and runner drain still share the same pod terminationGracePeriodSeconds budget. Consider increasing runner.terminationGracePeriodSeconds when dind.pruneOnStop is enabled, especially if runner.preStop.maxWaitSeconds + dind.pruneTimeoutSeconds approaches your grace period.

Persistent Workspace

By default, the runner's work directory (/home/runner/_work) is an emptyDir volume and is discarded when a pod restarts. Enable workspace to persist it across restarts.

Per-pod PVC (VolumeClaimTemplate)

Each runner pod gets its own dedicated workspace PVC:

helm install my-runners oci://ghcr.io/eric2788/charts/gha-stateful-runner \
  --set runner.repoUrl=https://github.com/your-org/your-repo \
  --set runner.token=YOUR_REGISTRATION_TOKEN \
  --set workspace.enabled=true \
  --set workspace.size=20Gi

Shared Existing PVC

Mount a single pre-existing PVC across all runner pods.

  • Set workspace.subPath to mount a fixed subdirectory.
  • Set workspace.subPathExpr to mount a dynamic expression.
  • workspace.subPathExpr takes precedence over workspace.subPath when both are set.
  • The chart does not inject env vars for subPathExpr; add any required env (such as POD_NAME) yourself.
helm install my-runners oci://ghcr.io/eric2788/charts/gha-stateful-runner \
  --set runner.repoUrl=https://github.com/your-org/your-repo \
  --set runner.token=YOUR_REGISTRATION_TOKEN \
  --set workspace.enabled=true \
  --set workspace.existingClaim=my-shared-workspace-pvc

Note

When using existingClaim, the PVC must support ReadWriteMany (e.g. NFS, CephFS) if you run more than one replica. Using ReadWriteOnce with multiple replicas will cause pod scheduling conflicts.

Extra Volume Mounts

Attach additional volumes (e.g. a shared Maven cache PVC) using top-level extraVolumes, then mount them from runner and/or DinD:

extraVolumes:
  - name: maven-cache
    persistentVolumeClaim:
      claimName: maven-cache-pvc

runner:
  extraVolumeMounts:
    - name: maven-cache
      mountPath: /home/runner/.m2

dind:
  extraVolumeMounts:
    - name: maven-cache
      mountPath: /var/lib/docker-cache

Re-registering a Runner

Runner credentials are cached in a PVC. If a runner's registration is lost (runner deleted from GitHub), re-register it by deleting the PVC for the affected pod:

# 1. Update the Secret with a freshly generated registration token FIRST
#    (tokens are only valid for ~1 hour from the moment they are generated)
kubectl create secret generic <secret-name> \
  --from-literal=ui_token=NEW_REGISTRATION_TOKEN \
  --dry-run=client -o yaml | kubectl apply -f -

# 2. Delete the credential PVC and the pod (replace <N> with the pod index 0, 1, 2, …)
kubectl delete pvc runner-creds-<release-name>-gha-sts-runner-<N>
kubectl delete pod <release-name>-gha-sts-runner-<N>

The StatefulSet recreates the pod automatically. Because the PVC was deleted, the init container runs config.sh again with the new token and saves fresh credentials.

Note

whenScaled: Retain is intentional -- scaling the StatefulSet down and back up preserves each runner's credential PVC so the runner reconnects to GitHub without re-registration. Only delete a PVC when you explicitly need to force re-registration for a specific runner.

Uninstalling

helm uninstall <release-name>

Because the PVC retention policy is whenDeleted: Delete, uninstalling the chart deletes the StatefulSet and all credential PVCs automatically — no manual PVC cleanup is needed.

However, the runner entries remain registered in GitHub Settings → Actions → Runners as offline runners. You must remove them manually from the GitHub UI (or via the API), or GitHub will auto-remove them after approximately 14 days of inactivity.

Autoscaling

For dynamic, queue-based autoscaling, prefer GitHub's official Actions Runner Controller (ARC) with runner scale sets.

This chart is designed for stateful, persistent runners with PVC-backed credentials and a fixed runner.count. If you need elastic scale-up/scale-down from workflow demand, use ARC instead of third-party scalers.

Security Notes

  • Runner containers run as non-root user (UID 1001) with all Linux capabilities dropped.
  • The Kubernetes API token is not mounted by default (automountServiceAccountToken: false). Enable it and create appropriate RBAC only if your CI jobs need to interact with the Kubernetes API.
  • DinD runs as a privileged container and should only be enabled when necessary.
  • For production, use runner.secret to reference a pre-created (or externally managed) Secret rather than passing runner.token in values.

About

Stateful Self-Hosted GitHub Actions Runner for Kubernetes – Secure Credential Persistence with UI Token Only (No PAT or GitHub Apps Needed)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages