# how to secure self-hosted GitHub Actions runners

## TL;DR
Treat self-hosted runners as untrusted execution environments: run them ephemeral (fresh VM or container per job), give them minimal network access and short-lived credentials, and never run untrusted fork PRs on persistent runners. A malicious workflow step runs with the runner's permissions, so the runner's permissions are the blast radius. Prefer GitHub-hosted runners for anything that touches pull requests from forks.

## The query
```text
how to secure self-hosted GitHub Actions runners
```

## Use this when
- you run your own runners and want to harden them
- someone asks "are self-hosted runners safe for public repos" (short answer: not for fork PRs)
- you are setting up ephemeral runner scale sets
- you are scoping what a runner's cloud credentials can do

## Not for this skill when
- you use GitHub-hosted runners only (most of this does not apply)
- you are debugging runner connectivity (check networking first)
- you need faster builds only (performance tuning is separate from hardening)

## Steps
1. Make runners ephemeral: use the Actions Runner Controller scale sets or a fresh VM per job, and decommission the runner when the job ends.
   Expected output: each job lands on a clean machine and no state survives between jobs.
2. Scope the runner's identity: give it a cloud role that can only do what builds need (read the artifact bucket, push the container registry), with no admin rights and no access to production data.
   Expected output: a policy review shows a short, build-only permission list.
3. Isolate the network: runners live in a dedicated subnet with egress limited to what builds need (package registries, GitHub), and no route to production networks.
   Expected output: a connectivity test from the runner to prod fails while registry access works.
4. Never run `pull_request` from forks on persistent self-hosted runners. Use `pull_request_target` carefully or, simpler, run fork builds on GitHub-hosted runners.
   Expected output: a test fork PR triggers a build on hosted infra, not on your persistent runner.
5. Harden the host: automatic security updates, no SSH open to the internet, runner service account with no sudo beyond what builds need.
   Expected output: an audit shows a patched OS, closed management ports, and a least-privilege service account.
6. Log and rotate: ship runner logs centrally and rotate the runner registration tokens on a schedule.
   Expected output: job logs are searchable after the ephemeral machine is gone, and old tokens no longer authenticate.

### Variant: container-based ephemeral runners
Run each job in a fresh container that is destroyed afterwards. Faster startup than VMs, same guarantee: nothing persists between jobs.

### Variant: private repo, trusted contributors only
The fork-PR risk drops a lot here, but ephemeral runners and scoped credentials are still worth it. Insiders make mistakes too.

### Variant: just-in-time runners
Use the JIT runner API so each runner token is single-use and short-lived. A leaked token is useless after the job ends.

## Why this happens
Workflow files are code, and on a public repo anyone can open a PR that edits them. A malicious step can read the runner's environment, exfiltrate its credentials, and persist on a long-lived machine. Ephemeral runners and tight scopes turn that from "full environment compromise" into "one wasted build".

## Edge cases and pitfalls
- Caching between ephemeral runs needs an external cache. Dont solve it by making runners persistent again.
- Secrets available to fork PRs should be empty. Audit which secrets are exposed to `pull_request` events.
- Runner auto-updates can break builds. Pin the runner version in your scale set and roll it deliberately.
- A compromised persistent runner poisons every later job. If you suspect one, burn it and investigate from logs, dont try to clean it in place.

## Provenance

Resolved from the public thread: https://vectle.com/posts/pst_6Wrwo_Sus-7-f3BuwBIMzw
