Hosted build minutes are convenient and predictable. At some point most organisations move part of their pipeline to self-hosted runners, usually for hardware, network reach, or cost. The runner that gets installed is a small agent with a large job: it receives work from a control plane, unpacks it on a machine the organisation owns, and executes it with whatever identity that machine has.
What the runner actually is
GitHub's documentation describes self-hosted runners as machines that run the runner application and connect to GitHub to pick up jobs. The distinction that matters is who can send work. A runner registered at the repository level accepts jobs from that repository. A runner registered at the organisation or enterprise level accepts jobs from every repository in scope, subject to group and label routing.
That scope is the whole security argument. A runner is not a sandbox. It executes steps defined in a workflow file, and a workflow file is code from the repository. Anybody who can merge to a branch that triggers a workflow on that runner can run commands on that machine.
The documented hardening guidance says this plainly: self-hosted runners should not be used for public repositories, because untrusted pull requests can reach them. The guidance exists because the failure mode has been demonstrated repeatedly rather than theorised.
The three things a runner leaks
The first is environment. Runner machines accumulate credentials as a matter of course: a cloud role attached to the instance, a Docker socket, deployment keys, package registry tokens in a global configuration file, and the runner's own registration token material. A workflow step that prints environment variables or dumps a home directory is ordinary debugging that happens to be data exfiltration.
The second is persistence. A runner machine survives between jobs unless it is ephemeral. Files written by one job are present for the next, and tooling installed for one build changes the environment for everything that follows. That is convenient for caching and a cross-repository contamination path when runners are shared.
The third is network position. Runners are often placed inside a private network so they can reach internal registries, databases, and deployment targets. The placement is the point of self-hosting, and it also means a compromised job has a foothold with unusual reach.
Ephemeral runners change the calculus
The strongest structural control is to make each job run on a fresh machine. GitHub documents autoscaling configurations where runners are created for a job and destroyed afterwards, and the same pattern is available with container-based runners that start from a clean image per job.
The benefit is that persistence disappears. Cached artifacts must be fetched deliberately through a cache action rather than found on disk, and one job cannot leave a modified binary behind for the next. The cost is startup time and the engineering work of building a reliable image, which is why the migration is usually incremental: ephemeral runners for untrusted or shared workloads first, persistent runners for the builds that genuinely need state.
Controls that carry weight
Scope the runner to the smallest repository set that needs it. An organisation-level runner should be a deliberate choice with a documented reason, not a default because it was easier to register once.
Restrict which workflows can request which runners using labels and, where available, runner groups with repository restrictions. Labels are routing hints, not an authorisation boundary on their own, but group restrictions are.
Require approval for workflow runs from forks, and disable the settings that let pull requests from forks execute workflows without review. This is a repository setting with direct effect on what reaches the runner.
Attach the minimum identity to the machine. A runner that deploys should have credentials for the deployment target only, not a broad role that happens to include it.
Log the runner's own activity. The job history in the control plane shows which workflow ran on which runner at what time, which is the correlation needed when a step behaves unexpectedly.
The review question
For every self-hosted runner, the useful question is a sentence rather than a checklist: which repositories can send code to this machine, and what can that code reach from where it lands. If the answer includes a repository the team does not control, or a network segment that holds production secrets, the runner's placement and scope need to change before the next build.
References
GitHub Docs, about self-hosted runners: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners
GitHub Docs, security hardening for GitHub Actions: https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions
GitHub Docs, autoscaling with self-hosted runners: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/autoscaling-with-self-hosted-runners