# Self-hosted GitHub Actions runners: ARC, security, and cost (https://tenki.cloud/blog/self-hosted-runners-arc-security-guide)

> For the complete documentation index, see [llms.txt](https://tenki.cloud/llms.txt)

- Author: Eddie Wang (Engineering)
- Published: Jul 31, 2026
- Updated: Sep 22, 2026
- Category: [CI/CD](https://tenki.cloud/blog/category/ci-cd)
- Reading time: 12 min

How self-hosted GitHub Actions runners register and autoscale with ARC, an 8-point hardening checklist, and the operational work you take on.

Self-hosted runners are the GitHub Actions escape hatch. When hosted runners are too slow, too expensive or too locked down, you bring your own machines and register them with GitHub. You get control of the hardware and network, and you take on the VMs, the patching and every CVE that lands between your update cycles.

This guide covers how runners register, how Actions Runner Controller (ARC) scales them on Kubernetes, a hardening checklist, and the operational work that comes with self-hosting. The last sections cover where a managed provider like [Tenki](https://tenki.cloud/products/runners) removes that work, and where it doesn't.

## How self-hosted runner registration works

Every self-hosted runner starts with a registration token. You generate one from your repository, organization or enterprise settings and pass it to `./config.sh`. The token [expires after one hour](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/add-runners), but once registration completes the runner stores a long-lived credential on disk (the `.credentials` file). With that credential, the runner holds an HTTPS long-poll connection open to the GitHub Actions service.

The runner idles until GitHub dispatches a job whose `runs-on` labels match. It then downloads the actions the job uses, runs the steps, streams logs back and reports the result.

Runners can be scoped to a repository, an organization or an enterprise. Repository runners serve one repo. Organization runners can take jobs from many repos, which means fewer machines but a wider blast radius if one is compromised. Enterprise runners can be shared across organizations.

## Ephemeral vs. persistent runners

By default, self-hosted runners are persistent: they finish a job and go back to waiting for the next one. The filesystem survives between runs. Warm caches can speed up later jobs, but credentials, temp files and artifacts from one workflow are still there for the next. A malicious step can drop a binary into the workspace that a later, more privileged job runs.

Ephemeral runners fix this. Pass `--ephemeral` to `config.sh` and the runner deregisters after a single job. The process exits, and your provisioning layer (Terraform, an autoscaling group, ARC) tears down the VM or container and starts a fresh one.

The cost is cold-start time: every job gets a new machine and re-fetches its dependencies. You trade the cache hits of a persistent runner for a clean filesystem.

GitHub also supports just-in-time (JIT) runners through the REST API. Instead of pre-registering a runner, you request a JIT configuration when a job is queued, boot a fresh machine and start it with `./run.sh --jitconfig`. The runner executes at most one job. ARC uses JIT configurations for its ephemeral runners. GitHub's docs add a caveat: if you reuse hardware for JIT runners, you still need automation to give each one a [clean environment](https://docs.github.com/en/actions/reference/security/secure-use).

## Actions Runner Controller: Kubernetes-native autoscaling

Actions Runner Controller is a Kubernetes operator in the [`actions/actions-runner-controller`](https://github.com/actions/actions-runner-controller) repository. It has two modes. The legacy mode (the `actions.summerwind.net` APIs) is maintained by the community only. GitHub supports only the newer runner scale sets mode, which became [generally available in 2023](https://github.com/actions/actions-runner-controller/discussions/2775). The rest of this section is about scale sets.

### How the scale set architecture works

Scale sets ship as two Helm charts: `gha-runner-scale-set-controller` deploys the controller into your cluster, and `gha-runner-scale-set` creates an `AutoscalingRunnerSet`, the runner pool itself. A minimal values file for the second chart looks like this:

```yaml
githubConfigUrl: https://github.com/my-org
githubConfigSecret: arc-github-app # Kubernetes secret with GitHub App credentials
runnerScaleSetName: linux-build
minRunners: 0
maxRunners: 20
```

The flow, [per GitHub's docs](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller), simplified:

1. The controller registers the scale set with the GitHub Actions service.
2. A listener pod opens an HTTPS long-poll connection to GitHub and waits for jobs.
3. When a job's `runs-on` matches the scale set's name or labels, the listener receives a "Job Available" message.
4. The listener patches the `EphemeralRunnerSet` with the desired replica count through the Kubernetes API.
5. The `EphemeralRunner` controller requests a JIT configuration and creates a runner pod. The pod registers, runs the job and is deleted when it finishes.

If a runner pod fails, the controller retries up to five times. If no runner accepts the job within 24 hours, GitHub unassigns it.

### What ARC requires you to maintain

ARC moves the operational work onto Kubernetes rather than removing it. You're now responsible for:

* A production Kubernetes cluster, with node pools, RBAC, network policies, monitoring and an upgrade path of its own.
* Upgrades to the controller and scale set Helm charts, which release regularly. If you fall behind, you miss bug fixes and security patches to the controller.
* A custom runner image. GitHub publishes a minimal runner container image, and most teams need more tooling than it has, so you maintain your own Dockerfile, rebuild it on a schedule and scan every layer.
* The GitHub credentials ARC authenticates with: a GitHub App installation or a personal access token stored as a Kubernetes secret. Either one needs rotation and monitoring.
* Scaling configuration: you set `minRunners`, `maxRunners` and pod resource requests. If they're too high you pay for idle pods, and if they're too low jobs queue at peak. With `minRunners: 0`, a job that lands when no node has room waits for your node autoscaler to add one.

## Security hardening checklist for self-hosted runners

GitHub's own [security guide](https://docs.github.com/en/actions/reference/security/secure-use) is blunt: 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." GitHub requires approval before fork pull requests from first-time contributors run workflows, but that is a setting you can loosen, and it only helps if the approver reads the diff.

Private repos aren't safe by default either. If your organization allows forking private repositories and running workflows from fork pull requests (both are settings), anyone who can fork the repo, which generally means anyone with read access, can run code on your runner and reach its secrets and `GITHUB_TOKEN`. The eight steps below close most of those paths.

### 1. Use ephemeral runners

The `--ephemeral` flag makes the runner deregister after one job. Pair it with VM or container teardown so the filesystem is destroyed too. Persistent runners are a known-bad pattern for any environment that runs untrusted or semi-trusted code.

### 2. Scope runners with runner groups

Organization runners that serve every repo have a large blast radius. Use runner groups to control which repositories can send jobs to which runners, so a compromised workflow in a low-trust repo can't land on the pool that runs your production deploys. Creating groups beyond the default one requires the GitHub Team plan or higher.

### 3. Restrict network access

Runners need outbound HTTPS to GitHub and to the registries your builds pull from. They don't need your production VPC, your cloud provider's metadata service or the internal network. Use Kubernetes network policies or VM firewall rules to deny everything except the egress you need. Block the cloud metadata endpoint at `169.254.169.254` explicitly; it's a well-known privilege escalation path.

### 4. Pin actions to full-length commit SHAs

This applies to all runners, and matters more on self-hosted ones where the blast radius is larger. Tags are mutable: a compromised action repo can re-point `v7` at a malicious commit. A full SHA can't move.

```yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
```

GitHub has repository and organization policies that require actions to be pinned to a full-length SHA.

### 5. Minimize `GITHUB_TOKEN` permissions

Set the default `GITHUB_TOKEN` permissions to read-only at the organization level, then grant write scopes per job where needed. On a compromised runner, a token with `contents: write` lets an attacker push to your repo.

```yaml
permissions:
  contents: read

jobs:
  release:
    runs-on: linux-build
    permissions:
      contents: write
    steps:
      - run: ./scripts/release.sh
```

### 6. Patch the runner binary and the base image

The runner application updates itself by default. If you disable that with `--disableupdate`, GitHub [stops queuing jobs](https://docs.github.com/en/actions/reference/runners/self-hosted-runners) to runners that aren't updated within 30 days of a new release. The OS, container base image and preinstalled tools only get patched when you patch them. Set a rebuild cadence (weekly is a sensible floor for production) and scan images with a tool like Trivy or Grype before you push them.

### 7. Use OIDC instead of long-lived cloud keys

If your workflows deploy to AWS, GCP or Azure, configure OpenID Connect federation so each job gets short-lived credentials scoped to that workflow run. Then there are no static keys in GitHub secrets for an attacker to take.

### 8. Guard workflow files with CODEOWNERS

Add `.github/workflows/` to your CODEOWNERS file with your platform team as owners, and turn on "Require review from Code Owners" in the branch rule. Any PR that changes a workflow then needs their approval before it merges. This doesn't stop someone from exploiting a workflow that already exists, but it stops new attack paths from slipping in unreviewed.

## The ongoing operational work

Teams usually plan for the hardening checklist. The recurring work after launch gets less attention, and it keeps growing over time.

The runner binary is a .NET application that gets regular security fixes. If you pin versions for stability, which is common in regulated environments, you carry the exposure between a fix's release and your next rollout, and the 30-day rule above sets a hard deadline.

Base images drift. GitHub [updates the software](https://github.com/actions/runner-images) on its hosted runner images weekly. Your images change only when your team rebuilds them. When what developers expect (current `ubuntu-latest`) and what the runner has (your last rebuild) drift apart, you get CI failures that are hard to trace.

Cost is hard to attribute. Self-hosted runners don't use GitHub-hosted minutes, but the compute still costs money. The instances, the cluster, the NAT gateway, the volumes and the data transfer all land on your cloud bill as generic compute. Attributing CI spend to a repo or team takes custom tagging and someone to maintain it.

On-call is the last piece. When jobs queue because the cluster hit a node limit, a runner pod is stuck in `CrashLoopBackOff` or the listener lost its connection to GitHub, someone gets paged. That person needs to understand Kubernetes, the ARC custom resources and the GitHub Actions API at the same time, and that knowledge tends to sit with one or two people.

## What a managed runner takes off your plate

Most of this work isn't specific to your product, which is the case for handing it to a provider. This is how [Tenki Runners](https://tenki.cloud/products/runners) handle each item above:

* Runners are ephemeral by default. Every job runs in a fresh VM with its own kernel, provisioned at job start and [destroyed at job end](https://tenki.cloud/docs/trust/security.md), whether the job passes or fails. VMs are never reused across jobs or customers. There's no `--ephemeral` flag or teardown automation to get right.
* Cold starts don't need a warm pool. A Tenki job [cold-starts in about 15 seconds on average](https://tenki.cloud/docs/runners/limits-and-concurrency.md), regardless of runner size or concurrency. With ARC, the cold start depends on your cluster, and keeping `minRunners` above zero to hide it means paying for idle pods. Tenki has no standby pool and no idle charges.
* Tenki maintains the images. Linux runners use GitHub's official [`actions/runner-images`](https://tenki.cloud/docs/runners/runner-images.md) (Ubuntu 24.04 by default), so jobs that run on `ubuntu-latest` run unchanged. Tenki refreshes the images monthly to match GitHub's, so versions can briefly lag GitHub's weekly updates. Fully custom images are an Enterprise option; on other plans you install extra tooling in the job.
* You don't run a cluster, so there are no Helm charts, custom resources or listener pods. Jobs run on bare-metal 5th Gen AMD Ryzen servers that Tenki operates. Jobs above your plan's concurrency limit (5 on Starter, 100 on Team) queue instead of failing. In [Tenki's benchmarks](https://tenki.cloud/docs/runners/benchmarks.md), identical workflows ran 20 to 60% faster than on `ubuntu-latest`.
* The bill is readable. x64 runners cost $0.002 per core-minute ([pricing](https://tenki.cloud/docs/pricing.md)), billed per second of job runtime. Queue time isn't billed, and you can set a daily spending limit. There are no NAT gateway or volume charges to untangle.
* Migration is a one-line change. Replace the whole `runs-on` value with a single Tenki label. Tenki labels can't be combined with `self-hosted` or `linux` in an array, so drop those.

```yaml
# before
runs-on: [self-hosted, linux, x64]
# after
runs-on: tenki-standard-medium-4c-8g
```

The [Migration Wizard](https://tenki.cloud/docs/runners/quickstart.md) in the Runners dashboard can open that pull request for you. See [Runner Sizes & Labels](https://tenki.cloud/docs/runners/sizes.md) for the full list.

## When self-hosting still makes sense

Some workloads need machines you control:

* If compliance requires an air-gapped environment, where code and build artifacts never leave your network, no external runner provider can help.
* GPU builds, FPGA synthesis or jobs that talk to on-premises hardware (firmware flashing, device labs) need physical machines you control. Tenki doesn't offer GPU runners.
* Some jobs need private network access, such as integration tests that hit databases or services you can't expose to the internet. Tenki runners egress from a fixed public range shared across customers; a dedicated IP range for allowlisting and private connectivity into your cloud are [scoped on request](https://tenki.cloud/docs/runners/networking.md), not standard.

For everything else, the question is whether the control is worth the infrastructure, the on-call rotation, the image maintenance and the hardening.

## A decision framework

Before you commit to self-hosted runners, work through these questions:

1. Do you need hardware or network access that no provider offers? If yes, self-host. If not, keep going.
2. Is cost the main driver? Compare the fully loaded cost of self-hosting (compute, cluster, NAT and transfer charges, engineering time for maintenance and on-call) with a managed provider's per-minute rate multiplied by your actual job minutes.
3. Is performance the main driver? Benchmark your own workflows. A self-hosted runner on a shared cloud VM isn't automatically faster than a hosted one; matching bare-metal performance usually means paying for dedicated instances.
4. Do you have the platform team to support it? ARC needs Kubernetes expertise. VM fleets need Terraform and Packer. Both need GitHub Actions API knowledge and a patching rotation.

If you're looking at self-hosted runners because GitHub-hosted runners are too slow or too expensive, try a managed provider on one workflow first. Change the `runs-on` label on a single job and compare run times and cost side by side.