Self-Hosted Runner
A self-hosted runner is a machine you operate that registers with GitHub and runs workflow jobs. How registration, labels, and the ownership split work.
A self-hosted runner is a machine you operate that registers itself with GitHub and executes GitHub Actions workflow jobs. GitHub hands that machine a job when every label in the job's runs-on key appears on the runner, and everything below the runner agent, meaning the hardware or instance, the operating system, the installed tooling, the network placement, and the bill for the machine, belongs to you.
The term describes ownership of the machine and nothing else. It says nothing about the size of the machine, its operating system, how long it lives, or whether a person or a controller created it. A workstation under a desk, an instance in your cloud account, a container in a Kubernetes cluster, and a Mac mini in a rack are each a self-hosted runner once a registered agent is running on them.
Definition
The two parts
A self-hosted runner is a host plus an agent, and only the host is yours.
The agent is the open source runner published at github.com/actions/runner under the MIT license. GitHub runs the same binary on the machines it supplies, so the software that downloads actions, executes steps, and streams logs is identical in both cases. The self-hosted part is the host underneath it: you choose what the machine is, where it sits, what is installed on it, and how long it stays alive.
That split explains why moving a workflow between fleets is usually a small change. The job definition talks to the agent, and the agent behaves the same way everywhere.
What registration establishes
Registration is a one-time handshake performed by config.sh on Linux and macOS or config.cmd on Windows. The command takes a registration URL, a short-lived registration token that GitHub shows on the New self-hosted runner page, an optional runner group, and a list of labels. After that, the agent authenticates with credentials it stored on disk, and the registration token is never needed again.
Three things are fixed at that moment.
- Scope. A runner registers to a single repository, to an organization, or to an enterprise. Scope decides which workflows are allowed to queue jobs on it. A repository-scoped runner is invisible to every other repository, which is the most common reason a correct-looking label never matches.
- Group membership. Organizations and enterprises place runners in groups, and a group carries a policy about which repositories and which workflows may use the runners inside it.
- Labels. The routing keys GitHub matches against
runs-on. Labels can be edited later from the runner settings page or with the API.
Once configured, the agent opens an outbound HTTPS long poll to GitHub and holds it. Job messages arrive on that connection, so the machine can sit inside a private subnet behind a firewall that allows outbound traffic only. This is the property teams are usually buying when they register their own runners: a job running on that machine resolves internal hostnames, reaches a private package registry, and connects to a database that has no public endpoint.
The labels a runner advertises
GitHub assigns three labels to every self-hosted runner automatically, and you add more at configuration time.
| Automatic label | Values GitHub assigns |
|---|---|
| Category | self-hosted, on every self-hosted runner |
| Operating system | linux, windows, or macOS |
| Architecture | x64, ARM, or ARM64 |
Matching is a subset test in one direction. Every label named in runs-on has to appear on the runner, while labels the runner carries beyond that set are ignored. A runner advertising self-hosted, linux, x64, and gpu-free is eligible for a job asking for [self-hosted, linux], and a runner advertising only self-hosted and linux is skipped by a job asking for [self-hosted, linux, x64].
Labels are opaque strings. GitHub performs no verification that a runner labeled x64 is an x86-64 machine or that a runner labeled secure-network sits in one. Whoever registered the runner owns the accuracy of its labels.
What you own
The registration relationship is cheap to create. The operating responsibilities behind it are the substance of the term, and they persist for as long as the fleet exists.
| Responsibility | What it involves | Typical failure when it is skipped |
|---|---|---|
| Provisioning | Creating machines, attaching disks, placing them in a subnet, installing the agent, holding the registration token | Capacity is fixed at whatever was created by hand, so a busy afternoon queues behind it |
| Patching and image maintenance | Operating system updates, language runtimes, build tools, container engines, and the agent version itself | Jobs pass on one machine and fail on another because the toolchain drifted apart |
| Scaling | Adding runners as queue depth grows, removing them when it falls, and paying for the machines in between | Either jobs wait for a free runner or idle machines bill for hours with nothing to run |
| Isolation | Deciding the boundary between two jobs: a separate virtual machine, a container on a shared kernel, or a shared working directory | A job reads the previous job's checkout, its credentials on disk, or its exported environment |
| Decommissioning | Deregistering the runner, terminating the machine, and deleting its disk | Offline runners accumulate in the settings page and orphaned disks keep billing |
None of these responsibilities are held by GitHub for a self-hosted runner. GitHub accepts the registration, routes jobs to it, and records the result. The rest of the loop belongs to whoever operates the fleet, which is why the choice is usually framed as an operations budget rather than a workflow decision.
Two policy defaults sit on top of the list. Self-hosted runners are blocked on public repositories until a runner group is changed to allow them, and GitHub recommends against that combination because a pull request from a fork can propose a workflow change that then executes on your machine (checked on 2026-08-13). The setting itself lives on the runner group, described in the public repositories documentation.
What the term does not decide
Two questions people fold into the term are separate from it.
Lifecycle. A self-hosted runner can accept one job and disappear, or stay registered for months and accept hundreds. Configuring the agent with --ephemeral produces the first shape, and leaving the flag off produces the second. The word "self-hosted" is silent on this, which is covered under ephemeral runner.
Who built the machine. A provider can operate machines and register them against your organization through the same registration path an engineer would use by hand. GitHub records those as self-hosted runners too, applies the same runner group rules and the same public repository policy to them, and routes jobs to them with the same label match. The registration is identical, and the difference is which team carries the five responsibilities in the table above.
Example
Take an organization named acme whose integration tests need a Postgres instance that lives inside a private subnet with no public endpoint. A job on a machine outside that network cannot reach the database, so acme registers a runner inside it.
Provision the machine. One Ubuntu 24.04 instance in the private subnet, with outbound HTTPS allowed and no inbound rules. Docker, the language toolchain, and a psql client are installed by the image build.
Register the agent. The runner is downloaded from the releases page GitHub links on the New self-hosted runner screen, then configured unattended:
mkdir actions-runner && cd actions-runner
curl -o actions-runner.tar.gz -L "$RUNNER_DOWNLOAD_URL"
tar xzf actions-runner.tar.gz
./config.sh \
--url https://github.com/acme \
--token "$RUNNER_REGISTRATION_TOKEN" \
--runnergroup integration \
--labels internal-network,postgres-fixtures \
--unattended
sudo ./svc.sh install
sudo ./svc.sh startThe --url value is what creates the relationship: this agent now belongs to the acme organization, inside the runner group named integration. The agent adds self-hosted, linux, and x64 to the two custom labels, so the runner advertises five labels in total and appears as Idle under the organization's runner settings.
Route a job to it. The workflow asks for all five:
name: integration
on:
pull_request:
jobs:
db-tests:
runs-on: [self-hosted, linux, x64, internal-network, postgres-fixtures]
steps:
- uses: actions/checkout@v4
- run: ./scripts/wait-for-postgres.sh
- run: ./scripts/integration-test.shWhen a pull request opens, GitHub queues db-tests, finds the runner in the integration group carrying all five labels, and sends it the job message on the connection the agent is already holding. The agent claims the job, spawns a worker, downloads actions/checkout, and runs the two scripts. Because the machine sits in the subnet, wait-for-postgres.sh resolves the private hostname and connects.
When the labels do not match
Drop postgres-fixtures from the runner and leave the workflow alone, and the job queues forever with an empty log. GitHub reports no error, because a queued job waiting for a label combination is a normal state. The job is cancelled once it has waited 24 hours, per GitHub's Actions limits, checked on 2026-08-13. A label typo therefore fails silently a day later rather than failing fast, so label changes are worth making on the runner and the workflow in the same change.
After the job ends
The agent reports the conclusion and returns to waiting. Registration outlives the job, and so does the disk: the checkout stays in the working directory, the package manager cache stays warm, and pulled container images stay in the local store. The next job that lands on this runner starts inside that history, which is the tradeoff long-lived runners make.
Moving the same job to another fleet
Because a managed fleet registers through the same path, redirecting the job is a change to one line:
jobs:
db-tests:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- run: ./scripts/integration-test.shA label of the shape warp-ubuntu-latest-x64-4x packs an operating system, an architecture, and a machine size into one opaque routing string, and GitHub matches it exactly the way it matched the five-label array. The steps, the triggers, and the rest of the file stay where they are. What changes is the network the job runs in, so the Postgres step in the first example stays on the runner inside the subnet while jobs with no private dependency move freely.
Related Terms
- GitHub-hosted versus self-hosted runners, compared: the same ownership split laid out against GitHub-hosted and managed fleets, dimension by dimension.
- Ephemeral runner, defined: the lifecycle question the term leaves open, and what changes when a runner takes one job and disappears.
- Runner group, defined: the policy container that decides which repositories and workflows may queue jobs on a registered fleet.
- Runner label, defined: how the routing keys are assigned, matched, and edited after registration.
- Enabling runners in public repositories: the runner group setting that governs self-hosted access from public repositories.
- Runner security documentation: the compute and storage isolation model applied to runner virtual machines.
- WarpBuild pricing: per minute rates by runner type.
FAQ
What is a self-hosted runner in GitHub Actions?
A self-hosted runner is a machine you supply and operate, with GitHub's open source runner agent installed on it and registered against a repository, an organization, or an enterprise. GitHub queues a workflow job for that machine when every label in the job's runs-on key appears on the runner, and the operating system, the disk, the network placement, and the cost of the machine stay yours.
How does a self-hosted runner connect to GitHub?
The agent opens an outbound HTTPS long poll to GitHub and waits for a job message on that connection. Nothing dials the machine from the internet, so a runner works inside a private subnet behind a firewall that allows outbound traffic only, and it can reach private services that a machine outside your network cannot.
What do I have to operate myself when I run self-hosted runners?
Provisioning the machines, building and patching the image the jobs run on, scaling the fleet up and down against queue depth, deciding the isolation boundary between jobs, and decommissioning machines so a deregistered runner leaves nothing running. GitHub operates none of that for a self-hosted runner.
Can self-hosted runners run jobs from public repositories?
Only after a runner group is changed to allow it. GitHub blocks self-hosted runners on public repositories by default and recommends against the combination, because a pull request from a fork can propose workflow changes that then execute on your machine.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.