How to run self-hosted GitHub Actions runners on OpenShift

By Brian Gomes, Owner and Principal Engineer. Published .

Every build I run happens on runners I built myself. They're container images that start as pods in OpenShift when a workflow needs them, run the job, and shut down when the build ends. Nothing sits idle between builds, and we don't really pay anything per run outside the CPU and memory the pods use while they run.

This is how that setup works, what to decide before you build it, and what goes wrong the first time.

Why teams move off GitHub-hosted runners

  • Security policy. Hosted runners run your code on machines you don't control. When policy or data rules keep builds off shared hosted runners, for internal code or for anything that deploys into a private network, self-hosted is the way to go.
  • Cost. Hosted runners bill by the minute, and larger runners cost more per minute. Once several teams build all day, the bill adds up. A runner pod on a cluster you already run only uses what the job uses. Check GitHub's current billing docs before you build the business case, because pricing changes.
  • Reach. A runner inside the cluster can reach internal registries and the cluster itself without opening a path in from the internet.

Long-lived runners or ephemeral runners

A long-lived runner is a server or pod that registers once and takes job after job. It's easy to start with. The problem is that every job leaves something behind: files in the workspace, cached credentials, tools a previous job installed. One bad job can affect every job after it.

An ephemeral runner takes one job and then goes away. GitHub supports this directly: a runner registered with the --ephemeral flag unregisters itself after one job. On OpenShift that maps cleanly to a pod. Start it when a job is queued, let it exit when the job is done, and the next job gets a clean one. That's the shape I run, and I wouldn't go back.

If you start runners yourself, the difference is one flag when the runner registers:

./config.sh --url https://github.com/my-org/my-repo \
  --token "$RUNNER_TOKEN" \
  --ephemeral --unattended
./run.sh

There are two ways to get there. GitHub maintains Actions Runner Controller, a Kubernetes operator that scales runner pods up and down based on queued jobs. Or you build your own runner image and your own way of starting it. Either way, the runner image is yours to build and keep patched.

With Actions Runner Controller it's two Helm installs, pinned to one chart version (0.14.2 is the latest release as of September 2026, so check for a newer one): the controller, then a runner scale set that points at your repository or organization. The scale set authenticates as a GitHub App, whose credentials go in a Kubernetes secret first.

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

kubectl create namespace my-runners
kubectl create secret generic my-github-app \
  --namespace my-runners \
  --from-literal=github_app_id=123456 \
  --from-literal=github_app_installation_id=654321 \
  --from-file=github_app_private_key=private-key.pem

helm install my-runners \
  --namespace my-runners \
  --set githubConfigUrl="https://github.com/my-org/my-repo" \
  --set githubConfigSecret=my-github-app \
  --set minRunners=0 \
  --set maxRunners=5 \
  --version 0.14.2 \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

A workflow then asks for the scale set by its install name, and each job gets a fresh pod:

jobs:
  build:
    runs-on: my-runners
    steps:
      - run: echo "running on a fresh runner pod"

Building the runner image

  • Start from a base image you trust and pin it by digest, not by a tag that can move.
  • Install only what your builds need. Every tool in the image is one more thing to scan and patch. I scan runner images with Trivy the same way I scan application images.
  • Run as non-root. OpenShift won't let the pod run as root by default, and it assigns a random user ID. The runner's home and work directories have to be writable by that user, which usually means group 0 ownership and group write permission.
  • Keep secrets out of the image. The runner needs a registration token to join your repository or organization. Get a short-lived token when the pod starts, from a GitHub App or your secrets manager. Never bake a personal access token into the image.

Building images inside the runner

On a laptop or a VM, a workflow runs docker build and it works. In a non-root pod on OpenShift there is no Docker daemon, and giving the runner the privileges to run one defeats the point of locking it down. You have a few options.

  • Hand the build to the cluster. OpenShift BuildConfig can build the image and push it to your registry, so the runner never needs build privileges.
  • Use a daemonless builder such as Buildah. It can run rootless, but on OpenShift it still needs extra setup and permissions, so talk to whoever owns the cluster early.
  • Keep a small pool of VM runners for the few jobs that truly need a daemon, and label them so only those jobs land there.

What goes wrong

  • Jobs sit in the queue. The labels in the workflow's runs-on don't match the labels the runner registered with, or whatever starts the pods can't see the queued job. Check the labels first.
  • Pods stay pending. The namespace quota is smaller than the resources the runner pod asks for, so it never gets scheduled. Size the requests from what builds actually use.
  • Every build is slow. A fresh pod has no cache, so each job downloads the same dependencies again. Point package managers at an internal mirror, and keep the image small so it pulls fast.
  • Permission errors at start-up. Almost always the random user ID trying to write to a directory it doesn't own.
  • A public repository can use your runner. GitHub warns against self-hosted runners on public repositories, because a pull request from a fork can run code on your infrastructure. Keep them to private repositories. The GitHub Actions security guidance covers this and more.

When it makes sense to get help

If you have a few repositories and no policy against hosted runners, stay on GitHub's hosted runners. They're simple and they work. Self-hosted runners start to pay off when policy or data rules keep builds off shared hosted runners, when the runner bill keeps growing, or when builds need to reach things inside your network.

At that point the work is the image, the scaling, the permissions and the build path, and then keeping all of it patched. I run all of that myself, so I know it's doable. It's also real work every month. If nobody on your team owns it, it's worth bringing in someone who has run it in production.

Related: the security gates I put in every pipeline and scanning images with Trivy in GitHub Actions.