Skip to content
Latchkey LogoLatchkey home

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

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.

Job lifecycle from queued to completed, and the three kinds of machine that can claim a job
The only thing your workflow chooses is the label in step 2. Everything after it belongs to whoever owns the machine.

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 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 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.

Key takeaways

  • A runner is the machine plus the application on it that claims a job and streams back the log.
  • The connection is outbound HTTPS on 443, so a runner must already be running to receive work.
  • An array in runs-on is an AND: one unmatched label means the job queues forever.
  • Each run: step is a new shell, which is why a tool on the PATH in one step is missing in the next.
  • The three kinds differ in who is responsible for the machine, and everything else follows from that.

Frequently asked questions

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.

Related guides

References

Latchkey is the managed kind: someone else owns the fleet, and you own one line of YAML. Start free → 30-day trial · No credit card