GitHub Actions Runner

A GitHub Actions runner is the machine that executes one workflow job, picked by the labels in runs-on. How runners register, match jobs, and bill.

A GitHub Actions runner is the machine that executes one job from a GitHub Actions workflow. Which machine picks up that job is decided by the labels in the job's runs-on key, because GitHub hands a queued job only to an online runner that advertises every label the job asked for.

Most of what confuses people about runners follows from that single matching rule. A job that never starts, a job that runs on the wrong hardware, and a job that finds a stale node_modules directory from a previous run are all label and lifecycle questions rather than workflow syntax questions.

Definition

A runner has two parts. The first is a machine: a virtual machine, a container, or a physical host. The second is the runner agent installed on that machine. The agent is open source, published at github.com/actions/runner under the MIT license, and it is the same binary whether GitHub supplies the machine or you do.

The agent is configured once with config.sh on Linux and macOS or config.cmd on Windows. Configuration takes a registration token, a registration URL, an optional runner group, and a list of labels. From then on the agent authenticates with credentials it stored locally, so the registration token is short lived and is used only at setup.

How a runner gets work

The agent opens an outbound HTTPS long poll to GitHub and waits. A runner therefore needs no inbound network access and no public address, which is why a runner works inside a private subnet behind a firewall that allows outbound connections only.

When a job in a workflow becomes eligible to run, GitHub places it in a queue and sends a message to a runner that satisfies the job. The agent then does the following, in order:

  1. Receives the job message on its open connection and acknowledges it, which claims the job so that no other runner takes it.
  2. Spawns a worker process for the job so that a crash in the job does not take down the listener.
  3. Downloads every action referenced by a uses step, resolving the ref to a commit and fetching the action's repository archive.
  4. Runs each step in sequence, streaming stdout and stderr back to GitHub in chunks so that logs appear live in the run view.
  5. Uploads any files the job asked for through actions/upload-artifact and any cache entries written by actions/cache.
  6. Reports the job conclusion (success, failure, cancelled, or skipped) and returns to waiting, or deregisters if it was configured as ephemeral.

One runner runs one job at a time. Concurrency across a repository comes from having more runners, not from a single runner accepting parallel jobs. A matrix of twelve jobs needs twelve eligible runners to run all twelve at once.

How labels route a job

runs-on accepts three shapes. A single label is written as a string. Several labels are written as an array, and the runner must carry all of them. A third object form selects a runner group and then filters inside it:

jobs:
  string-form:
    runs-on: ubuntu-latest

  array-form:
    runs-on: [self-hosted, linux, x64]

  group-form:
    runs-on:
      group: production-runners
      labels: [linux, x64]

Matching is a subset test in one direction. Every label in runs-on must appear on the runner. Labels the runner carries beyond that set are ignored, so a runner advertising self-hosted, linux, x64, and large is eligible for a job requesting [self-hosted, linux]. A runner advertising only self-hosted and linux is skipped for a job requesting [self-hosted, linux, x64].

Labels are opaque strings. GitHub does not verify that a runner labeled x64 is an x86-64 machine, or that a runner labeled large has more than two cores. The label is a routing key chosen by whoever registered the runner, and keeping it honest is the registrar's job.

Registration scope and runner groups

A runner registers at one of three scopes: a single repository, an organization, or an enterprise. Scope decides which workflows are allowed to queue jobs on that runner. A repository-scoped runner is invisible to every other repository, which is the most common reason a correct-looking label never matches.

Runner groups add a second filter on top of scope. An organization can place a fleet in a group and then restrict the group to named repositories, or to public repositories only, or to selected workflows. The object form of runs-on shown above is how a job targets a specific group by name.

GitHub documents a queue limit for jobs waiting on self-hosted runners: a job that has been queued for 24 hours is cancelled (GitHub Actions limits, checked on 2026-08-13). A job stuck behind a label typo therefore fails silently a day later rather than failing fast.

Persistent runners and ephemeral runners

Lifecycle divides runners again, and the split matters more than machine size for build correctness.

Persistent runners stay registered and accept job after job. Whatever the previous job left on disk is still there: a populated package manager cache, Docker layers, a checked out repository, and also a half written file from a job that was cancelled. Warm caches make repeat builds faster. Leaked state makes failures hard to reproduce, because the failing input is the machine's history rather than the commit.

Ephemeral runners are started with --ephemeral. The agent accepts exactly one job, then deregisters and exits. Every job begins on a machine with a known starting state.

Ephemeral registration is what makes autoscaling correct. A controller watches the queue depth, starts one runner per queued job, and lets each runner disappear when its job ends, with no risk that a runner is terminated mid job. Actions Runner Controller, GitHub's Kubernetes based controller, works this way, and so do managed runner providers.

The three hosting categories

Every runner falls into one of three categories. The categories differ in who owns the machine and who operates the agent, and nothing else about the matching rules changes between them.

CategoryWho supplies the machineWho operates the agentTypical labels
GitHub-hostedGitHubGitHububuntu-latest, windows-latest, macos-latest
Self-hostedYou, in your own cloud account or data centerYou, or a controller such as Actions Runner Controllerself-hosted plus custom labels you choose
ManagedA runner providerThe provider, through the same GitHub registration APIProvider specific labels

GitHub-hosted runners are provisioned fresh for each job from GitHub's pools and discarded when the job finishes. The machine sizes, the preinstalled software, and the regions are fixed by GitHub. Larger runner shapes are available on paid plans and are selected by their own labels.

Self-hosted runners are machines you supply and register. You choose the operating system, the machine size, the preinstalled tooling, the disk, and the network placement. You also own patching, image builds, autoscaling, and the idle cost of any machine that is up but not running a job.

Managed runners sit between the two. A provider supplies the machines and registers them against your organization through the same registration API a self-hosted runner uses. Because registration is identical, GitHub treats them as self-hosted runners, and moving a workflow between fleets is a change to one runs-on line rather than a rewrite of the workflow.

Example

This workflow sends its job to a GitHub-hosted Linux runner. ubuntu-latest is the label GitHub documents for its default hosted Linux image.

name: test
on:
  push:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test

When that push lands, GitHub queues one job named unit-tests, selects a machine from its hosted Linux pool, and streams the log lines from npm ci and npm test into the run view. The machine is destroyed afterwards.

Claiming the same job with a different runner

Changing the label changes which fleet claims the job. Nothing else in the file moves:

jobs:
  unit-tests:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test

The job is now queued for a runner advertising the label warp-ubuntu-latest-x64-4x. A runner carrying that label picks up the message, claims the job, and runs the same six steps. Labels of this shape encode the operating system, the architecture, and the machine size, so the label alone tells a reader which machine the job lands on.

The table below maps the Linux labels in the WarpBuild runner catalog to the machine each one selects. Sizes are from the WarpBuild cloud runners documentation, checked on 2026-08-13.

LabelOSvCPURAMStorageAlias
warp-ubuntu-latest-x64-2xUbuntu 24.0428 GB150GB SSDwarp-ubuntu-2404-x64-2x
warp-ubuntu-latest-x64-4xUbuntu 24.04416 GB150GB SSDwarp-ubuntu-2404-x64-4x
warp-ubuntu-latest-x64-8xUbuntu 24.04832 GB150GB SSDwarp-ubuntu-2404-x64-8x
warp-ubuntu-latest-arm64-2xUbuntu 24.0428 GB150GB SSDwarp-ubuntu-2404-arm64-2x
warp-ubuntu-latest-arm64-4xUbuntu 24.04416 GB150GB SSDwarp-ubuntu-2404-arm64-4x
warp-ubuntu-latest-arm64-8xUbuntu 24.04832 GB150GB SSDwarp-ubuntu-2404-arm64-8x

Architecture is part of the label rather than part of the workflow body, so a matrix can fan the same steps across both architectures:

jobs:
  build:
    strategy:
      matrix:
        runner:
          - warp-ubuntu-latest-x64-4x
          - warp-ubuntu-latest-arm64-4x
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - run: make build

One image detail is worth knowing before a job is moved across architectures: the Ubuntu 24.04 ARM64 image sets the work directory to /runner/_work, while GitHub's own runners use /home/runner/work/. A step that hardcodes the GitHub path fails on the ARM64 image. The Linux ARM64 runner page lists the rest.

Labels that carry options

Some fleets accept options appended to the label with a semicolon. The label is still one opaque routing string to GitHub, and the provider parses the suffix on its side:

jobs:
  integration:
    runs-on: warp-ubuntu-latest-x64-2x;snapshot.key=my-project-snapshot
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/integration-test.sh

That form asks for a runner booted from a saved machine image rather than a cold base image. The snapshot runners answer page covers when a job benefits from one, and the snapshot runner page lists the label options.

How runners are billed

Billing follows the hosting category, and the three models are structurally different.

GitHub-hosted runners are billed by GitHub per minute of job time, rounded up per job, at published list prices. Plans include an allowance of standard runner minutes for private repositories, and jobs in public repositories run at no charge on standard runners. Beyond the allowance, and for every larger runner shape, the meter runs from the first minute.

Take a repository that runs 1,000 jobs a month at 6 minutes each, which is 6,000 job minutes. Applying GitHub's published list prices gives the following monthly bill for the job time alone. Rates are from GitHub's Actions billing reference and the plan pages at github.com/pricing, checked on 2026-08-13.

GitHub-hosted runnerShapeList price per minute6,000 minutes
ubuntu-latest on private repositories2 vCPU, 8 GB$0.006$36.00
4-core Linux larger runner4 vCPU, 16 GB$0.012$72.00
8-core Linux larger runner8 vCPU, 32 GB$0.022$132.00
16-core Linux larger runner16 vCPU, 64 GB$0.042$252.00
ubuntu-24.04-arm on private repositories2 vCPU, 8 GB$0.005$30.00
4-core Windows larger runner4 vCPU, 16 GB$0.022$132.00
macOS ARM64 xlarge5 vCPU, 14 GB$0.102$612.00

Two things fall out of that table. Machine size drives the bill more than job count does, so a workload that finishes in fewer minutes on a bigger machine does not automatically cost less. And platform matters more than size: the same 6,000 minutes cost $36.00 on standard hosted Linux and $612.00 on the hosted macOS ARM64 shape.

Self-hosted runners carry no GitHub per minute charge. You pay your cloud provider for instance time instead, and the unit is wall clock hours rather than job minutes:

monthly cost = instances x hours running x hourly instance rate

The gap between those two units is where self-hosted budgets go wrong. Those same 6,000 job minutes are 100 hours of work. A single always-on runner is up for roughly 730 hours in a 30 day month, so the fleet is idle about 86 percent of the time while still being billed. Scaling to zero between jobs closes the gap, which is the argument for ephemeral runners plus a queue-driven controller. Storage, network transfer, and image build time sit on top.

Managed runners are billed per minute of job time by the provider, in the GitHub-hosted style, while the operational model stays in the self-hosted style. Taking the label from the example above as the reference point, warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13. Current per minute rates by runner type are on the WarpBuild pricing page, and the quick start guide walks through pointing a first workflow at a managed fleet.

FAQ

What does a GitHub Actions runner actually do?

A runner is a machine plus the open source runner agent installed on it. The agent registers with GitHub, advertises a set of labels, holds an outbound long poll waiting for work, then runs the steps of one job and streams logs and the final conclusion back to GitHub.

How does GitHub decide which runner gets a job?

GitHub matches the labels in the job's runs-on key against the labels each online runner in scope advertises. A runner is eligible only when it carries every label the job requested. Extra labels on the runner are ignored, and the first eligible runner to pick up the message claims the job.

What is the difference between GitHub-hosted, self-hosted, and managed runners?

GitHub-hosted runners are provisioned and discarded by GitHub from its own pools. Self-hosted runners are machines you supply and register yourself. Managed runners are machines a provider supplies and registers through the same GitHub registration API, so the workflow change is a label change.

Why is my GitHub Actions job stuck in the queue with no logs?

No online runner in scope advertises every label in runs-on. The usual causes are a typo in a label, a runner registered at the repository scope when the workflow lives in another repository, a runner group that excludes the repository, or a fleet with every runner already busy.

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.