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.
- Kubernetes 1.25+
- Helm 3.x
- A GitHub Actions runner registration token (obtained from GitHub → Repository → Settings → Actions → Runners → New self-hosted runner)
- In GitHub, open your repository and go to Settings -> Actions -> Runners.
- Click New self-hosted runner.
- Copy the runner registration token shown in the setup page (this is the
ui_token, valid for about 1 hour). - 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_TOKENNote
runner.repoUrland eitherrunner.tokenorrunner.secretare required.- Version is not pinned in these examples so new chart releases are picked up automatically.
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| 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 | "" |
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-tokenEnable 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=trueWarning
DinD requires privileged: true. Ensure your cluster's PodSecurity policy or admission controller allows privileged containers.
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=20GiSpecify 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=20GiWarning
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=180Note
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.
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.
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=20GiMount a single pre-existing PVC across all runner pods.
- Set
workspace.subPathto mount a fixed subdirectory. - Set
workspace.subPathExprto mount a dynamic expression. workspace.subPathExprtakes precedence overworkspace.subPathwhen both are set.- The chart does not inject env vars for
subPathExpr; add any required env (such asPOD_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-pvcNote
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.
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-cacheRunner 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.
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.
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.
- 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.secretto reference a pre-created (or externally managed) Secret rather than passingrunner.tokenin values.