Skip to content
Dev Tools Advanced Tutorial

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.

Rachel Goldstein
Rachel Goldstein
Dev Tools Editor · Sep 5, 2026 · 5 min read
Ephemeral GitHub Actions Runners on Kubernetes with ARC

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 kubectl admin 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: repo scope for a repository-level install, repo and admin:org for 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

  1. Quickstart for Actions Runner Controller — docs.github.com
  2. Deploying runner scale sets with Actions Runner Controller — docs.github.com
  3. Troubleshooting Actions Runner Controller errors — docs.github.com
  4. Actions Runner Controller releases — github.com
  5. Actions Runner Controller release 0.14.0 — github.blog
Rachel Goldstein
Written by
Rachel Goldstein · Dev Tools Editor

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

Join the discussion

Sign in or create an account to comment and vote.

Noor Haddad @indiehacker_noor · 3 hours ago

ephemeral runners on k8s is the move, but curious about the compute cost vs shared runners. cold start tax worth it for isolation?

Maya Ito @opensource_maya · 9 hours ago

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?

Will Carter @weekend_warrior_will · 17 hours ago

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.

Ken Abe @perf_obsessed_ken · 21 hours ago

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.

Oleg Petrov @db_nerd_oleg · 23 hours ago

ephemeral runners sound ideal for isolation, but curious how the pod spin-up latency compares to keeping warm replicas around

Related Reading