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.
Related Articles
The Ultimate Guide to Linux Server Management in 2025
A comprehensive guide to modern Linux server management covering automation, containerization, cloud integration, AI-driven operations, security best practices, and essential tooling for 2025.
Server & DevOpsFixing "421 Misdirected Request" for Plesk Sites on Ubuntu 22.04 After Apache Update
Resolve the 421 Misdirected Request error affecting all HTTPS sites on Plesk for Ubuntu 22.04 after an Apache update, caused by changed SNI requirements in the nginx-to-Apache proxy chain.
Server & DevOpsHow to Set Up GlusterFS on Ubuntu
A complete guide to setting up a distributed, replicated GlusterFS filesystem across multiple Ubuntu 22.04 nodes, including installation, volume creation, client mounting, maintenance, and troubleshooting.