Ephemeral GitHub Actions Runners on Kubernetes with ARC
Deploy Actions Runner Controller so every CI job gets a fresh pod that is deleted on completion.
What you'll build
You'll deploy Actions Runner Controller (ARC) to a Kubernetes cluster so that every GitHub Actions job spins up a fresh runner pod, runs, and gets deleted. No shared runner VMs, no leftover state between jobs, no long-lived registration tokens sitting on disk.
Prerequisites
Verified against ARC 0.14.2 (the current stable gha-runner-scale-set charts, released May 2026) and GitHub's docs as of September 2026.
- A Kubernetes cluster with
kubectladmin access. Anything conformant works; kind or minikube is fine for trying this out. - Helm 3.8 or later. ARC's charts are published as OCI artifacts, and OCI support went stable in 3.8.
- A GitHub repository or organization you administer, plus a classic personal access token:
reposcope for a repository-level install,repoandadmin:orgfor an organization-level install. For production, switch to a GitHub App later (see Next steps). - You do not need cert-manager. That was a legacy-ARC requirement; the runner scale set charts drop it.
A note on naming before you start: ARC builds Kubernetes labels out of your Helm release name, so the installation name is capped at 45 characters and the namespace at 63. Keep both short.
1. Install the controller
The controller watches for runner scale set resources and manages the pod lifecycle. Install it into its own namespace:
helm install arc \
--namespace arc-systems \
--create-namespace \
--version 0.14.2 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
Confirm it's up:
kubectl get pods -n arc-systems
You should see one arc-gha-rs-controller pod in Running state within a minute.
2. Create the GitHub credentials secret
You could pass the PAT inline with --set githubConfigSecret.github_token=..., but that leaves the token in your shell history and in Helm release metadata. Create a Kubernetes secret instead and reference it by name:
kubectl create namespace arc-runners
kubectl create secret generic github-config-secret \
--namespace arc-runners \
--from-literal=github_token='<YOUR_PAT>'
The key must be named github_token. The chart looks for exactly that.
3. Deploy the runner scale set
This chart registers a runner scale set with GitHub and tells the controller how to scale it. The release name doubles as the label your workflows target, so pick something you're happy typing in runs-on:
helm install arc-runner-set \
--namespace arc-runners \
--version 0.14.2 \
--set githubConfigUrl="https://github.com/<your_org>/<your_repo>" \
--set githubConfigSecret=github-config-secret \
--set minRunners=0 \
--set maxRunners=5 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
Set githubConfigUrl to https://github.com/<your_org> instead to serve a whole organization (this is where admin:org scope matters). minRunners=0 means zero idle pods when the queue is empty; each queued job triggers a pod, and maxRunners caps concurrency.
This gets you a listener pod in arc-systems that long-polls GitHub for queued jobs. When one arrives, the controller creates an EphemeralRunner pod that registers with a just-in-time token, runs exactly one job, and is deleted.
One caveat: these runners have no Docker daemon by default, so docker build and container-based service jobs will fail. If you need that, add --set containerMode.type=dind to the install above.
4. Point a workflow at it
In the repo you configured, add .github/workflows/arc-test.yml:
name: ARC test
on: workflow_dispatch
jobs:
smoke-test:
runs-on: arc-runner-set
steps:
- run: echo "Running in pod $HOSTNAME on $(uname -m)"
The runs-on value must match your Helm release name from step 3 exactly. Commit, push, then trigger it from the Actions tab (or gh workflow run arc-test.yml).
Verify it works
Check both Helm releases landed:
helm list -A
NAME NAMESPACE ... STATUS CHART
arc arc-systems ... deployed gha-runner-scale-set-controller-0.14.2
arc-runner-set arc-runners ... deployed gha-runner-scale-set-0.14.2
The controller and listener should both be running:
kubectl get pods -n arc-systems
NAME READY STATUS RESTARTS AGE
arc-gha-rs-controller-576cd87f97-2ljhb 1/1 Running 0 5m
arc-runner-set-754b578d-listener 1/1 Running 0 2m
Now watch the runner namespace while your workflow runs:
kubectl get pods -n arc-runners -w
NAME READY STATUS RESTARTS AGE
arc-runner-set-rmrgw-runner-p9vqk 0/1 Pending 0 0s
arc-runner-set-rmrgw-runner-p9vqk 1/1 Running 0 4s
arc-runner-set-rmrgw-runner-p9vqk 1/1 Terminating 0 41s
That's the whole point in three lines: a pod appears for the job, runs it, and is gone. The job itself should show green in the Actions tab with your echo output. Trigger the workflow twice and you'll see two differently named pods, proving nothing is reused.
Troubleshooting
Name must have up to 45 characters during helm install. Your release name for the runner scale set is too long. ARC embeds it in Kubernetes labels, which have a 63-character ceiling, and reserves 18 for its own suffixes. Shorten the release name (the companion error Namespace must have up to 63 characters has the same fix for the namespace).
The listener pod never appears even though helm install succeeded. Almost always bad credentials or a bad URL. Check the controller logs:
kubectl logs -n arc-systems deployment/arc-gha-rs-controller
Look for authentication failures, then verify the secret actually contains a valid token under the github_token key and that githubConfigUrl points at a repo or org the token can administer. A URL pasted with a trailing slash or a .git suffix will also break registration.
failed to get access token for GitHub App auth: 401 Unauthorized in the listener or controller logs when using GitHub App auth. This one is sneaky: it's usually clock drift, not credentials. The App's JWT is time-signed, so if your nodes' clocks have drifted from GitHub's, authentication fails. Sync the nodes with NTP.
Access to the path /home/runner/_work/_tool is denied in job logs. You've mounted a persistent volume into a runner, and the non-root runner user (UID 1001, group 123) can't write to it. Use a volume type that honors securityContext.fsGroup and set fsGroup: 123 on the runner pod spec, or chown -R 1001:123 /home/runner/_work in an init container.
Next steps
Swap the PAT for a GitHub App before going to production: set github_app_id, github_app_installation_id, and github_app_private_key in the secret instead of github_token. Apps get higher rate limits and don't expire with an employee's account. From there, look at containerMode.type=kubernetes if you'd rather run job containers as pods than run Docker-in-Docker, runner groups for sharing one scale set across an org with access controls, and the controller's Prometheus metrics (gha_controller_pending_ephemeral_runners and friends) for autoscaling visibility. The deploying runner scale sets guide covers all three, including custom runner images when the stock ghcr.io/actions/actions-runner image is missing tools you need.
Sources & further reading
- Quickstart for Actions Runner Controller — docs.github.com
- Deploying runner scale sets with Actions Runner Controller — docs.github.com
- Troubleshooting Actions Runner Controller errors — docs.github.com
- Actions Runner Controller releases — github.com
- Actions Runner Controller release 0.14.0 — github.blog
Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.
Discussion 5
ephemeral runners on k8s is the move, but curious about the compute cost vs shared runners. cold start tax worth it for isolation?
the ephemeral isolation is solid, but how do you handle artifact/cache persistence across jobs without introducing state back into the pods? are people just relying on external storage integrations, or is there a pattern in the ARC community that's emerged?
the ephemeral part is what sold me—i had a runner pod go haywire last month and leak secrets into logs because some cached dependency got stale, and digging through the audit trail was a nightmare. fresh pod every time means that can't happen.
sounds clean until you hit the scale scenario — what's the p99 pod spin-up time when you've got 50 jobs queuing up and your cluster's thrashing on resource requests? and the article doesn't mention etcd/api server load from constant pod creation/deletion, or how you're handling image pull latency on every single job. yeah, ephemeral is safer, but the operational blast radius on a misconfigured resource request or a registry outage gets way bigger.
ephemeral runners sound ideal for isolation, but curious how the pod spin-up latency compares to keeping warm replicas around