# What is a GitHub Actions runner, and which kind should run your jobs

> What is a GitHub Actions runner? The machine that executes a job: hosted, self-hosted or managed, how runs-on labels pick one, and the job lifecycle.

Source: https://latchkey.dev/learn/ci-explained/what-is-a-github-actions-runner  
Updated: 2026-09-20

What is a GitHub Actions runner? It is the machine that takes one job off GitHub's queue and executes its steps, plus the small application on that machine that does the taking. Your workflow file chooses almost nothing about it: one `runs-on` line, and the rest is somebody's infrastructure.

A workflow file describes work. Something has to do it, and that something is a runner: a virtual machine, a container or a physical box with the GitHub Actions runner application installed, connected to GitHub and waiting for a job that matches its labels.

The reason this is worth understanding rather than skipping is that most of the confusing things about Actions live on this boundary. Why a tool installed in one step is missing in the next, why a job sits in a queue, why a build behaves differently from your laptop, why a private repository gets a smaller machine than a public one: all of them are runner questions wearing workflow clothes.

## The machine, and the program on it

Two things are called "the runner" and it is worth separating them. There is the machine, which has CPU, memory and a disk, and there is the runner application, which is an open source program that connects to GitHub, asks for work, executes what it is given and streams the log back. Everything your job can do is bounded by the first, and everything GitHub knows about your job comes through the second.

The connection is outbound only. GitHub documents the requirement as the host being "able to make outbound HTTPS connections over port 443", and nothing reaches into your network to start a job. That is why a self-hosted runner works behind a firewall with no inbound rules, and it is also why the runner has to be running before a job can reach it: there is no mechanism for GitHub to wake up a machine that is not already connected.

The application is also what implements the things that feel like magic. It downloads each action before running it, sets up the environment files that steps use to pass values to each other, evaluates expressions, and uploads logs. When an action fails in a way that has nothing to do with your code, it is usually this layer, which is why the runner version appears at the top of every job log.

## Three kinds, distinguished by who owns the machine

The categories are not about capability. They are about who is responsible when the machine is wrong, and that single question predicts everything else about the tradeoff.

- **GitHub-hosted.** GitHub owns the machine, provisions a clean one per job and discards it afterwards. Chosen with labels like `ubuntu-latest` or `macos-26`, billed per minute, free on public repositories. You get [a fixed set of shapes](/learn/ci-explained/github-actions-runner-specs) and a preinstalled toolchain, and no control over either.
- **Self-hosted.** You own the machine. You install the runner application, you choose the hardware, and GitHub documents the consequence plainly: "you are responsible for updating the operating system and all other software". It gets the `self-hosted` label plus labels for its OS and architecture, and you can add your own.
- **Managed by a vendor.** Someone else owns the machine and registers it as a self-hosted runner on your behalf. From your workflow it looks like a self-hosted runner, because it is one: you change the `runs-on` label and nothing else. Latchkey labels are `latchkey-small` through `latchkey-xlarge`.

## How runs-on actually picks one

The `runs-on` key is how a job names the type of machine it wants, and it takes three shapes. A single label is the common case. An array of labels means, in GitHub's words, that "your workflow will execute on any runner that matches all of the specified `runs-on` values", which is an AND and not an OR, and is the most common source of a job that queues forever: one label in the array matches nothing, so no runner qualifies.

The third shape is a runner group plus labels, used when an organization wants to control which repositories can reach which machines. Combining them tightens rather than loosens: "when you combine groups and labels, the runner must meet both requirements to be eligible to run the job". For self-hosted runners the `self-hosted` label goes first in the array by convention, and a runner picks up its OS and architecture labels automatically when it registers.

There is no fallback anywhere in this. A job whose labels match no connected runner waits, and it waits without complaining in a way that looks like GitHub being slow rather than like a configuration error. If a job is stuck at "waiting for a runner to pick up this job", read the labels before anything else.

```.github/workflows/ci.yml
# One hosted label
runs-on: ubuntu-24.04

# Every label must match
runs-on: [self-hosted, linux, x64, gpu]

# A managed vendor runner: one line changes
runs-on: latchkey-medium

# A group and a label, both of which must match
runs-on:
  group: ubuntu-runners
  labels: ubuntu-24.04-16core
```

## The life of a job

GitHub exposes these as the stages of the `workflow_job` webhook, which "corresponds to the stages of a workflow job's life-cycle; for example when jobs are `queued`, `in_progress`, and `completed`". Knowing where a problem sits in this sequence narrows it faster than any log.

- **Queued.** An event fires, the workflow is evaluated, and the job is placed in a queue with its label requirements. Nothing has been assigned yet. Time spent here is capacity or a label mismatch, never your code.
- **Assigned.** A runner whose labels satisfy the job claims it. On GitHub-hosted runners a machine is provisioned at this moment, which is where cold start time comes from.
- **In progress.** The runner sets up the job, downloads each action, and executes steps in order. A `run:` step is a shell invocation, and each one is a new shell, which is why an exported variable does not survive into the next step unless it went through `$GITHUB_ENV`.
- **Completed.** Every step has a status and the job has a conclusion. The runner uploads the last logs, and on a hosted or ephemeral runner the machine is destroyed here, taking everything not uploaded as an artifact with it.

## What the runner decides for you

Three things your workflow cannot override. The hardware, which on GitHub-hosted runners depends on the label and on whether the repository is public or private, and is 4 vCPU and 16 GB against 2 vCPU and 8 GB for the same Linux label. The image, meaning which tools are preinstalled and at which versions, which moves under a `-latest` label on a published schedule. And the isolation, meaning whether anything from the previous job is still on the disk.

That last one is the difference between an [ephemeral runner](/learn/ci-explained/ephemeral-runners-explained) and a persistent one, and it is the thing most worth checking about any runner you did not set up yourself. A machine that reuses state is faster and will eventually produce a failure nobody can reproduce.

Everything else is yours. Which tools you install, which cache you restore, what the steps do. The useful mental model is that the runner is the operating system of your pipeline: you do not usually think about it, and every unexplained problem turns out to live there.

## Which kind should run your jobs

Start hosted. For most repositories GitHub-hosted runners are correct, and the arguments against them only become real at a scale or with a constraint you will recognize when you have it: a bill that shows up in a budget meeting, a build that needs hardware GitHub does not sell, a network that requires jobs to originate from a specific address, or a queue that is genuinely costing developer time.

Self-host when the constraint is about control and you have somewhere to put the responsibility. You are taking on patching, isolation, capacity and the security question, and GitHub is clear that self-hosted runners "should almost never be used for public repositories on GitHub" because anyone can open a pull request. That is a real operational commitment rather than a configuration change.

Managed runners exist to split the difference: somebody else owns the machine and its isolation, and your workflow change is one label. The honest way to evaluate any of them is to move one noisy job and compare, because the interesting differences are cache behavior, cold start and what happens on a bad day, and none of those shows up in a specification table.

## FAQ

### When should you use self-hosted runners vs GitHub-hosted runners?

Use hosted runners until a specific constraint forces you off them: cost at scale, hardware GitHub does not offer, a network that requires a fixed egress address, or access to something inside your own perimeter. Self-hosting buys control and charges you patching, capacity planning and isolation, and it is close to disqualified for public repositories because anyone can open a pull request that runs on your machine.

### Which GitHub Actions services let you run jobs on faster hardware without self-hosting?

Two options. GitHub sells larger runners up to 96 vCPU with up to 2 TB of disk, billed per minute with no free tier. Managed runner vendors, Latchkey included, register machines as self-hosted runners on your behalf, so you change the `runs-on` label and keep the rest of the workflow. Both avoid owning the hardware; only the second lets you choose a provider.

### Should I self-host GitHub Actions runners?

Only if you can name the constraint that requires it and the person who will own the fleet. The costs are ongoing rather than one-off: operating system updates, runner version updates, capacity for peak, and an isolation model that keeps one job from reaching the next. If the motivation is purely cost or speed, a managed runner gets most of the benefit without the operational commitment.

### Why is my job stuck on "waiting for a runner to pick up this job"?

Because no connected runner satisfies every label in `runs-on`. With hosted runners that usually means a label that does not exist, a retired image name, or a concurrency limit on your plan. With self-hosted runners it means the runner is offline, is busy, or is missing one label from the array; `runs-on` is an AND, so a single extra label nothing carries is enough to queue the job indefinitely.

## References

- [Choose the runner for a job: runs-on, label arrays and groups (read 2026-09-20)](https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/choose-the-runner-for-a-job)
- [About self-hosted runners (read 2026-09-20)](https://docs.github.com/en/actions/concepts/runners/self-hosted-runners)
- [Self-hosted runners reference: connectivity and the job life cycle (read 2026-09-20)](https://docs.github.com/en/actions/reference/runners/self-hosted-runners)
- [GitHub-hosted runners reference: labels and specifications (read 2026-09-20)](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
