Self-Hosted GitHub Actions Runners: They Dial Out, Not In


A matte sage-green cube with a single glowing cord reaching out through a small hole to touch a glowing orb outside it, next to a potted succulent and pebbles

Wiring a GitHub Actions job to build inside a private Kubernetes cluster, one with no public IP on its API server and a container registry that only resolves on the LAN, looks at first like it should need an inbound hole punched somewhere. A webhook, an exposed port, something GitHub can reach from the outside to say "build this now." It doesn't need any of that, and the reason is worth understanding on its own, separate from the specific pipeline it made possible.

The direction of the connection is the whole trick

A GitHub-hosted runner, the default kind, is a fresh virtual machine GitHub itself spins up per job, runs the job on, and destroys afterward. Nothing about that requires explaining, GitHub owns the machine.

A self-hosted runner is different in exactly one way: you provide the machine. Everything about how it connects to GitHub stays identical to the hosted case, and that's the part that isn't obvious from the outside. The runner process, once started, opens an outbound HTTPS connection to GitHub's own runner service and holds it open, waiting. GitHub never initiates a connection to the runner. It can't, the runner never listens on anything. The runner calls GitHub, not the other way around, and that single fact is the entire reason this works from inside a network with nothing exposed.

Registering is a one-time handshake, not a standing credential

Before any of that polling starts, the runner has to introduce itself. The usual flow is a short-lived registration token, generated from a repo's settings and valid for about an hour, handed to the runner process once at startup. That's fine for a runner started by a human at a keyboard. It's not fine for a Kubernetes Deployment, which might restart the pod at 3am for a completely unrelated reason and needs to register itself again without anyone awake to generate a fresh token.

The practical fix, and what actually runs here, is a runner image that takes a longer-lived credential, a fine-grained personal access token scoped to exactly one repository with the narrow "Administration: read and write" permission, and mints its own short-lived registration token from that on every single startup. The PAT never talks to the runner API directly. It only ever does the one thing: ask for a fresh hour-long registration token, then get out of the way. A pod that restarts unattended handles its own re-registration every time, and the credential that makes that possible is scoped to nothing more than exactly this.

What "claiming a job" actually looks like

Registered and connected, the runner mostly does nothing. It sits there holding its connection open, which is a legitimate description of most of its lifetime.

A workflow file is the other half of this. runs-on: self-hosted isn't a location, it's a label, and GitHub's side of the matching is closer to a job queue with routing rules than anything resembling a push. When a workflow run needs a job executed, GitHub's scheduler looks at every currently-connected runner, filters down to the ones whose labels are a superset of what the job asked for, and hands the job to one of them over the connection that runner already had open. A workflow asking for runs-on: [self-hosted, tailfws-cluster] will only ever be picked up by a runner that registered with both of those labels, out of however many self-hosted runners might exist across every repository. Nothing about that step touches the runner's network from GitHub's side. GitHub posts a job to a connection the runner opened; the runner was always the one doing the reaching.

Once claimed, everything happens on your hardware

From here the job is just a normal Actions job, actions/checkout, whatever steps a workflow file lists, executed in order. The only difference from a hosted runner is where it's actually running: on whatever compute registered with those labels, with whatever access that specific machine has and nothing more.

For a runner living as a pod inside a Kubernetes cluster, that access is the entire reason the pod exists. It can resolve and reach a container registry that only has a LAN DNS entry, because it's a workload running inside that same network, the same way any other pod is. It can call the cluster's own API server to roll out a new Deployment, because it authenticates with a ServiceAccount token Kubernetes mounts into every pod automatically, scoped by RBAC to patch exactly one Deployment in one namespace and nothing beyond that. None of that access was granted to GitHub. GitHub doesn't have it and was never asked for it. It's access the pod already had by virtue of where it lives, and the job inherits it only because the job is, for its duration, that pod.

The tradeoff nobody puts on the diagram

A hosted runner is a disposable machine that vanishes when the job ends, taking any state, any leftover files, any lingering process with it. A self-hosted runner, run as a long-lived Deployment instead of something ephemeral, doesn't get that for free. The same pod handles the next job too, and the one after that, on whatever filesystem and process state got left behind. That's a real operational difference, not a hypothetical one, and it's the reason the access that pod holds is worth being deliberate about. A ServiceAccount scoped to patch one Deployment in one namespace is a small, specific blast radius by design, not by default, because the alternative, a long-lived pod with broad cluster access, is a genuinely different risk than a VM that stops existing in ten minutes.

What this actually buys

Nothing here needed a firewall rule opened, a tunnel stood up, or a webhook endpoint exposed to the internet for GitHub to call. The runner reaches out, holds a connection, and waits for a job matching labels only it registered with, and every credential involved, the registration PAT, the ServiceAccount token, is scoped to the one narrow thing it's actually for. Once a job lands, it runs with exactly the access the runner already had, no more, which is what makes it possible to build a Docker image and deploy it into a cluster that has otherwise never let anything in from the outside.