Self-hosted runners trade GitHub's managed, disposable VMs for machines you control — useful when you need specific hardware, access to an internal network, or you're trying to cut minutes-based billing on a high-volume repo. That control comes with operational and security responsibilities GitHub-hosted runners quietly absorb for you, and the security ones in particular are easy to get wrong on a public repository.
Registering a runner
A self-hosted runner starts as a downloadable agent you run on your own machine or container. You register it against a repository, organization, or enterprise by running ./config.sh --url https://github.com/org/repo --token <registration-token>, which authenticates the machine and adds it to the pool. From there ./run.sh starts the listener that polls for queued jobs matching the runner's labels. Nothing about the runner itself is managed by GitHub past that point — OS patching, tool versions, disk cleanup, and process supervision are all on you.
Ephemeral vs. persistent runners
A persistent runner stays registered and reuses the same filesystem and environment across many jobs — faster to start, but state leaks between runs: leftover build artifacts, cached credentials, or a poisoned dependency from a previous job can affect the next one. An ephemeral runner (config.sh --ephemeral) deregisters itself and exits after a single job, so each run starts from a clean image. Ephemeral costs you the startup time of provisioning a fresh environment per job, but it removes an entire class of "why does this only fail on this runner" bugs and is the safer default for anything untrusted.
Runner groups and labels for routing
Runner groups control which repositories or organizations are allowed to use a given set of runners — useful for separating, say, a pool with access to internal systems from a general-purpose pool open to every repo in the org. Labels are the finer-grained routing mechanism: a workflow's runs-on: [self-hosted, linux, gpu] targets any runner carrying all three labels, which lets you pin specific jobs to machines with the hardware or network access they actually need, rather than every job racing for the same pool.
Autoscaling with actions-runner-controller
Running a fixed pool of always-on VMs wastes capacity outside peak hours and starves it during a deploy storm. actions-runner-controller (ARC) runs on Kubernetes and scales runner pods up and down based on queued job demand, using GitHub's webhook events or polling to know when to add capacity. It's the standard way to get self-hosted economics without babysitting a fixed fleet.
apiVersion: actions.github.com/v1alpha1
kind: AutoscalingRunnerSet
metadata:
name: internal-linux-runners
spec:
githubConfigUrl: https://github.com/my-org
githubConfigSecret: gha-runner-token
minRunners: 0
maxRunners: 20
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
The fork PR risk on public repos
This is the one that gets teams burned. GitHub-hosted runners for public repos run pull requests from forks with reduced permissions and no access to secrets by default, precisely because anyone can open a PR and have your CI execute arbitrary code from it. Point a self-hosted runner at a public repo's default pull_request trigger and you've handed arbitrary code execution, on your own infrastructure, to anyone who opens a PR — the workflow runs on a machine that can potentially reach your internal network, your cache, whatever else lives next to it.
pull_request_target runs with the base repo's permissions and secrets rather than the fork's, specifically so maintainers can do things like label PRs automatically. Combine that trigger with a self-hosted runner on a public repo and a forked PR can potentially exfiltrate secrets or pivot into your network using a job that was granted full trust. If you must build on forks with self-hosted runners, gate the run behind a required reviewer approval, and never check out and directly execute code from the fork inside a pull_request_target job.
Network and firewall considerations
Part of the appeal of self-hosted runners is reaching resources a hosted runner never could — an internal artifact registry, a database behind a VPN, an on-prem deployment target. That means the runner itself becomes a bridge between GitHub's job queue and your internal network, so it needs outbound access to GitHub (for polling and job data) and whatever internal resources the job touches, but ideally nothing more. Treat the runner host like any other service with network access to sensitive systems: least-privilege security groups, no inbound exposure, and monitoring on what it actually connects to during a job.
Self-hosted runners are worth it for hardware needs, internal network access, or cost at scale — but only with ephemeral runners as the default, labels and runner groups keeping jobs on the right pool, and a hard rule against running them against untrusted pull requests on public repositories.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.