runs-on in GitHub Actions: choosing the runner for a job
How runs-on picks a runner: GitHub-hosted labels, self-hosted label arrays, runner groups, and choosing the runner from a matrix or an expression.
runs-on is required on every job (except jobs that call a reusable workflow). It tells GitHub which machine, the runner, should run the job. It takes one of three forms:
runs-on: ubuntu-latest # one labelruns-on: [self-hosted, linux, arm64] # several labels: a runner must have all of themruns-on: # a runner group, optionally with labels group: large-runners labels: [linux-16-core]GitHub sends the job to a runner whose labels match. If none is online, the job waits in the queue: up to 24 hours for self-hosted runners before it is cancelled.
GitHub-hosted labels#
Each label is a fresh virtual machine, deleted when the job ends. The current labels:
| Label | OS | CPU (private / public repo) | RAM (private / public) |
|---|---|---|---|
ubuntu-latest, ubuntu-24.04, ubuntu-22.04, ubuntu-26.04 | Linux x64 | 2 / 4 | 8 / 16 GB |
ubuntu-24.04-arm, ubuntu-22.04-arm, ubuntu-26.04-arm | Linux arm64 | 2 / 4 | 8 / 16 GB |
ubuntu-slim | Linux x64, 1 vCPU container | 1 | 5 GB |
windows-latest, windows-2025, windows-2022 | Windows x64 | 2 / 4 | 8 / 16 GB |
windows-11-arm | Windows arm64 | 2 / 4 | 8 / 16 GB |
macos-latest, macos-15, macos-26 | macOS arm64 (M1) | 3 | 7 GB |
macos-15-intel, macos-26-intel | macOS x64 | 4 | 14 GB |
Every one of them has 14 GB of SSD storage, and ubuntu-latest currently points to Ubuntu 24.04. The -latest labels move to a new OS version a few months after it ships, which can break a workflow overnight: pin ubuntu-24.04 if you depend on exact package versions, and move on your own schedule. GitHub keeps the authoritative list in its runner reference ↗.
Bigger machines (4 to 96 vCPUs, GPUs) are larger runners: an organization admin creates them, and you target them by the name they were given or by their runner group.
Self-hosted labels#
A self-hosted runner gets default labels when it registers: self-hosted, its OS (linux, windows or macOS) and its architecture (x64, ARM or ARM64). You add custom labels at registration (--labels gpu,large) or in the settings. With an array, the job goes to a runner that has every label:
jobs: build: runs-on: [self-hosted, linux, x64, gpu]Labels are case-insensitive. Keep them specific: a job asking only for self-hosted can land on any of your runners, including the slow one under someone’s desk.
Runner groups#
Organizations put runners in groups to control which repositories can use them. Target a group with group:, alone or with labels:
runs-on: group: ubuntu-runners labels: ubuntu-24.04-16coreThe job only goes to runners of that group that also have the labels. Group access is checked first: a repository that isn’t allowed to use the group never gets its jobs picked up, and the job sits queued.
Choosing the runner dynamically#
runs-on accepts expressions, which is how one job definition covers several machines.
From a matrix:
jobs: test: strategy: matrix: os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 - run: make testFrom the event or an input:
on: workflow_dispatch: inputs: runner: type: choice options: [ubuntu-latest, ubuntu-24.04-arm]
jobs: build: # Big runner on main, standard runner elsewhere runs-on: ${{ github.ref == 'refs/heads/main' && 'linux-16-core' || inputs.runner || 'ubuntu-latest' }}From a configuration variable, so a repository setting can move every workflow to another runner without a commit:
runs-on: ${{ vars.DEFAULT_RUNNER || 'ubuntu-latest' }}And a label array can be built from JSON when the labels themselves vary: runs-on: ${{ fromJSON('["self-hosted", "linux", "x64"]') }}.
Reading the runner from a step#
Inside a job, the runner context and the RUNNER_* variables tell you where you landed: runner.os (Linux, Windows, macOS), runner.arch (X64, ARM64…), runner.name, and runner.environment (github-hosted or self-hosted). Use them in cache keys (${{ runner.os }}-${{ runner.arch }}-…) so x64 and arm64 jobs don’t share incompatible caches.
Things that go wrong#
- “Waiting for a runner to pick up this job” forever. No online runner has all the labels (check the spelling and the case of custom labels), the repository can’t use the runner’s group, or you hit your plan’s concurrency limit (20 concurrent jobs on GitHub Free, 60 on Team).
- A label that silently changed meaning.
ubuntu-latestandmacos-latestmove between OS versions. Pin versions for release builds. - Arm64 builds on x64. If a job uses QEMU to build arm64 images on
ubuntu-latest, switching it toubuntu-24.04-armusually makes it several times faster. See the matrix guide. runs-onon a reusable workflow call. A job withuses: ./.github/workflows/x.ymltakes noruns-on; the called workflow’s jobs choose their own runners.
runs-on with RunsOn#
RunsOn runs your jobs on EC2 instances in your own AWS account, and its labels describe the machine you want, per job:
jobs: build: runs-on: runs-on=${{ github.run_id }}/runner=16cpu-linux-arm64The first part ties the runner to this workflow run; the rest picks the size, the architecture, the image, the disk, and features such as Magic Cache. Any EC2 type works, from 1 to 896 vCPUs, GPUs included, usually at a fraction of GitHub’s per-minute price. See job labels for every option.