.png)
Cordyceps Exploits pull_request_target in 300+ Repos
Self-hosted runners are 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's orchestration layer. The tradeoff is real control for real responsibility: you own the VMs, the network, the patching cadence, and every CVE that lands between update cycles.
This guide covers the architecture from registration tokens through ARC-managed Kubernetes scale sets, walks through a practical security hardening checklist, and maps exactly where managed runner providers like Tenki collapse that operational surface so your platform team doesn't have to carry it.
Every self-hosted runner starts with a registration token. You generate one from your repository, org, or enterprise settings, then pass it to the runner binary during ./config.sh. The token is short-lived (one hour), but once registration completes the runner stores a long-lived OAuth credential in a .credentials file on disk. That credential is what lets the listener process maintain a persistent HTTPS long-poll connection to GitHub's Actions service.
The listener idles until GitHub dispatches a Job Available message whose runs-on labels match. At that point the runner binary clones the job workspace, executes steps, streams logs back over HTTPS, and reports final status.
Runners can be scoped at three levels: repository, organization, and enterprise. Repository runners serve a single repo. Org-level runners can process jobs across multiple repos, which reduces machine count but widens the blast radius if one gets compromised. Enterprise runners add another tier that spans organizations.
By default, self-hosted runners are persistent. They pick up a job, complete it, and loop back to the listener. The filesystem persists between runs. Caches from previous builds stick around, which can speed up subsequent jobs, but it also means credentials, temp files, and artifacts from one workflow leak into the next. A malicious workflow step can drop a binary into the workspace that a later, more-privileged job unwittingly executes.
Ephemeral runners fix this. Pass --ephemeral during configuration and the runner deregisters itself after completing a single job. The process exits, and your provisioning layer (Terraform, ASG, ARC, whatever) tears down the VM or container and spins up a fresh one. Clean filesystem. No carryover.
The cost is cold-start overhead. Every job spins a new machine and re-pulls dependencies. There's no free lunch here: you either accept the cache risk of persistent runners or pay the latency tax of ephemeral ones.
GitHub also supports just-in-time (JIT) runners via the REST API. Instead of pre-registering a runner and waiting for work, you request a JIT configuration token when a job is queued, boot a fresh machine, pass the token with ./run.sh --jitconfig, and the runner executes exactly one job. This is what ARC uses internally for its ephemeral runner sets.
Actions Runner Controller (ARC) is a Kubernetes operator maintained under the actions/actions-runner-controller GitHub org. It replaced the older community-maintained Summerwind controller in 2023 and is now the officially supported path for running self-hosted runners on Kubernetes.
ARC ships as two Helm charts. The first deploys the controller manager into your cluster. The second creates an AutoScalingRunnerSet custom resource, which is the actual runner pool.
The flow, simplified:
If a runner pod fails, the controller retries up to five times. If no runner accepts the job within 24 hours, GitHub unassigns it.
ARC is powerful, but it shifts the operational surface rather than removing it. You're now responsible for:
GitHub's own docs are blunt: self-hosted runners "should almost never be used for public repositories." Anyone who can open a pull request against your repo can trigger a workflow on your runner. On a persistent runner that means code execution on a machine that might still hold credentials from a previous job.
Even for private repos, the threat model isn't empty. Anyone with read access can fork the repo and open a PR, potentially compromising the runner environment and accessing secrets. Here's a practical hardening checklist.
Non-negotiable. The --ephemeral flag ensures the runner deregisters after one job. Pair it with VM or container teardown so the filesystem is destroyed. Persistent runners in 2026 are a known-bad pattern for any environment running untrusted or semi-trusted code.
Org-level runners that serve every repo create a massive blast radius. Use runner groups to restrict which repositories and workflows can target which runners. A compromised workflow in a low-trust repo shouldn't be able to land on the same runner pool that handles your production deploys.
Runners need outbound HTTPS to GitHub's API endpoints and whatever registries your builds pull from. They don't need access to your production VPC, your cloud provider's metadata service, or the internal network at large. Use network policies (in Kubernetes) or firewall rules (on VMs) to deny everything except the minimum required egress. The cloud metadata endpoint at 169.254.169.254 deserves a specific block rule; it's a well-known privilege escalation vector.
This applies to all runners, but it's especially critical on self-hosted ones where the blast radius is larger. Tags are mutable. A compromised action repo can re-point v4 to a malicious commit. Pinning to a full SHA makes the reference immutable. GitHub now offers repository and org-level policies to enforce SHA pinning.
Set the default GITHUB_TOKEN permission to read-only at the org level, then grant write scopes per-job where needed. On a compromised self-hosted runner, a write-scoped token lets an attacker push code directly to your repo.
GitHub's hosted runners get a fresh image every week. Your self-hosted runners don't. The runner binary itself receives automatic updates by default, but the underlying OS, container base image, and any pre-installed tooling only get patched when you patch them. Establish a rebuild cadence (weekly is the minimum for production) and scan images with Trivy or Grype before pushing.
If your workflows deploy to AWS, GCP, or Azure, configure OpenID Connect federation so the runner obtains short-lived credentials scoped to the specific workflow run. No static keys sitting in GitHub Secrets for an attacker to exfiltrate.
Add .github/workflows/ to your CODEOWNERS file with your platform team as reviewers. Any PR that modifies a workflow file then requires explicit approval before merge. This doesn't prevent exploitation of existing workflows, but it stops new attack vectors from being introduced silently.
Security hardening is the checklist everyone thinks about. The slower bleed is operational. Platform teams that self-host runners take on a set of ongoing responsibilities that compound over time.
Runner binary CVEs. The runner application itself is a .NET binary that receives regular security updates. By default it auto-updates, but if you've pinned versions for stability (common in regulated environments), you're carrying the exposure window between the CVE disclosure and your next rollout.
Base image drift. GitHub rebuilds its hosted runner images weekly with updated packages, new tool versions, and patched dependencies. On self-hosted runners, images only change when your team rebuilds them. Drift between what developers expect (the latest ubuntu-latest) and what the runner provides (your last rebuild) creates subtle, hard-to-debug CI failures.
Cloud cost visibility. Self-hosted runners are "free" in GitHub's billing, but the compute isn't free. The EC2 instances, the EKS cluster, the NAT gateway, the EBS volumes, the data transfer charges -- all of it hits your cloud bill as generic compute. Attributing CI cost per-repo or per-team requires custom tagging and a FinOps practice that most teams don't build until spend is already out of control.
On-call burden. When jobs queue because the ARC cluster hit a node limit, or a runner pod is stuck in CrashLoopBackOff, or the listener lost its GitHub connection, someone on your team gets paged. That person needs to understand Kubernetes, the ARC CRDs, and the GitHub Actions API simultaneously. It's a niche skill set that's hard to distribute across a team.
The reason managed runner providers exist is that most of the self-hosting surface is undifferentiated operational work. Your competitive advantage is your product, not your ability to patch container base images on a weekly cadence.
Tenki's managed runners collapse the self-hosted runner surface point by point:
Tenki also offers a Code Reviewer that operates as a review gate at the merge boundary. For teams that self-host runners partly to control what code ships, this is worth noting: the runner isn't where code quality enforcement should happen. A purpose-built review gate catches issues before CI even runs.
Self-hosting isn't always the wrong call. There are legitimate cases where it's the only option:
For everything else, the question is whether the operational cost of self-hosting is justified by the control it provides. Most teams that run the math discover it isn't. The infrastructure, the on-call rotations, the image maintenance, and the security hardening all cost engineering hours that could go toward shipping product.
Before committing to self-hosted runners, run through these questions:
If you're evaluating self-hosted runners because GitHub-hosted runners are too slow or too expensive, start by testing a managed provider instead. The migration is a single runs-on label change, there's no infrastructure to set up, and you can run a side-by-side comparison in an afternoon.
Tags
Recommended for you
What's next in your stack.