Skip to main content
Server & DevOpsAugust 25, 20265 min read

How to Run Ephemeral CI Runners on Your Own Hardware

A build that passes because of something left behind by the previous build is not a passing build, it is a coincidence. Ephemeral runners remove that class of problem by giving every job a machine that has never run anything else. GitHub supports this directly through single-use runner registration and just-in-time configuration, so the runner deregisters itself after one job and your automation disposes of the host. This guide covers both approaches, the Kubernetes version, and the one situation where self-hosted runners are the wrong answer.

Self-hosted runners start out as an easy win. You get the hardware you already pay for, the network access your builds need, and none of the queueing. Then a build passes on a machine where somebody once installed a package by hand, fails everywhere else, and the debugging starts.

The fix is to stop reusing runners. A runner that is created for one job and destroyed afterwards cannot inherit anything, cannot leak a credential into the next job, and cannot drift. GitHub supports this directly, and the mechanism is simpler than most teams expect.

What ephemeral means to GitHub

Register a runner with --ephemeral and the Actions service deregisters it automatically once it has processed one job.

./config.sh --url https://github.com/octo-org --token example-token --ephemeral

GitHub only assigns one job to an ephemeral runner. That is the property everything else here depends on, because it means the host has a defined lifetime and your automation knows exactly when it may be wiped. GitHub recommends autoscaling with ephemeral runners and explicitly does not recommend it with persistent ones.

Two consequences follow immediately. The runner's log files disappear with the host, so GitHub's documentation is direct about it, saying the log files for ephemeral runners must be forwarded to an external log storage solution for troubleshooting and diagnostic purposes. Set that up before you put this in front of a team, not after the first failure you cannot explain.

Skip the registration token with a JIT config

The config.sh flow needs a registration token on the machine, and that token expires after one hour. For a fleet that is provisioned constantly, there is a cleaner option. Ask the API for a just-in-time configuration and hand it straight to the runner process, and the box never holds a registration credential at all.

# generate a single-use configuration for one runner
ENCODED_JIT_CONFIG=$(gh api \
  --method POST \
  -H "Accept: application/vnd.github+json" \
  /repos/OWNER/REPO/actions/runners/generate-jitconfig \
  -f name="runner-$(uuidgen)" \
  -F runner_group_id=1 \
  -f "labels[]=self-hosted" \
  -f "labels[]=linux" \
  -f work_folder="_work" \
  --jq .encoded_jit_config)

# start the runner with it; no config.sh step, no registration token on disk
./run.sh --jitconfig "$ENCODED_JIT_CONFIG"

The endpoint exists at organisation level too, at POST /orgs/{org}/actions/runners/generate-jitconfig. GitHub describes it as generating a configuration that can be passed to the runner application at startup. The runner binary accepts it through the --jitconfig argument, which is part of the runner's own command line handling.

Wiring it to a real host

On a VM or a bare-metal box, the pattern is a unit that runs one job and then hands the machine back. What "hands back" means depends on your platform. It might be a cloud API call that terminates the instance, a call to your hypervisor that reverts a snapshot, or a reprovision from an image.

# /etc/systemd/system/gha-runner.service
[Unit]
Description=Single-use GitHub Actions runner
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=runner
WorkingDirectory=/opt/actions-runner
ExecStart=/opt/actions-runner/start-one-job.sh
# after the job, the host is no longer trustworthy: take it out of service
ExecStopPost=/opt/actions-runner/dispose.sh

[Install]
WantedBy=multi-user.target

The important discipline is that dispose.sh destroys rather than cleans. Deleting the workspace directory and moving on is the persistent-runner model with extra steps, and it leaves behind everything a job wrote outside that directory.

The Kubernetes version

If you already run Kubernetes, Actions Runner Controller does the provisioning and disposal for you. It is a Kubernetes operator that orchestrates and scales self-hosted runners, using a listener pod that holds a long poll connection to the Actions service and waits for a job-available message.

# the controller
helm install arc \
  --namespace arc-systems \
  --create-namespace \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

# a runner scale set; the install name is what you put in runs-on
helm install arc-runner-set \
  --namespace arc-runners \
  --create-namespace \
  --set githubConfigUrl="https://github.com/octo-org/octo-repo" \
  --set githubConfigSecret.github_token="${GITHUB_PAT}" \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
# .github/workflows/demo.yml
name: Actions Runner Controller Demo
on:
  workflow_dispatch:

jobs:
  explore:
    runs-on: arc-runner-set
    steps:
      - run: echo "This job uses runner scale set runners"

The value of runs-on has to match the Helm installation name, which catches people out the first time. Because the runners are containers created per job, scale-up and scale-down are both fast and clean.

The rule you do not break

GitHub's security guidance is unambiguous, and it says self-hosted runners should almost never be used for public repositories on GitHub, because any user can open pull requests against the repository and compromise the environment. A fork's pull request can run code on your hardware, inside your network. If the repository is public, use GitHub-hosted runners.

The same guidance names a leak that ephemeral runners fix as a side effect. Jobs that pass secrets as command-line arguments expose them to anything that can read the process table on the same host, so ps x -w from a parallel job is enough. One job per machine removes the parallel job.

Working out your own numbers

Any figure published in an article is somebody else's build profile and it will not survive contact with yours, so the only useful version of this question is the one you answer from your own meters.

Start with GitHub Actions usage metrics, which show how many minutes your workflows and jobs consume and let you break the same data down by workflow, by job, by repository and by runner type. That last view is the one to sit with, because it separates what is already running on self-hosted capacity from what is not. Export a full month rather than a week, since release cadence and dependency bumps make short samples lie. One caveat comes straight from the documentation, which is that usage metrics do not apply minute multipliers to the figures displayed, so the minutes shown are raw consumption rather than a reproduction of your bill.

Then account for the side that has no meter. Runners sit idle between builds and the capacity still has to exist, so the unit on your own hardware is provisioned capacity rather than busy minutes. Add the storage for images and caches, the external log retention that ephemeral runners require, and the engineering hours to keep the images patched. Those last three are the ones teams leave out of the first estimate and discover in the second month.

If you want the fleet built, imaged and monitored rather than described, our CI/CD pipeline setup covers the runner side as well as the workflows, and Kubernetes management covers the cluster underneath it.

Talk to the engineer who will own your stack.

No account managers, no offshore handoff. Senior DevOps, direct. Tell us what you are dealing with and you get a straight answer.