Manage GitHub Actions Runners at Scale: Self-Hosted, Larger Runners and Runner Groups (GH-200)
Managing GitHub Actions runners at scale means deciding which machines run an organization's jobs, controlling which repositories and workflows can reach them, keeping them connected and current, and making sure each job finds the software it needs. This lesson covers the GitHub Actions (GH-200) skills for that work: configuring and monitoring GitHub-hosted, larger and self-hosted runners; routing jobs with labels and runner groups; ephemeral runners and autoscaling; networking, proxies and IP allow lists; troubleshooting runners that stay queued or offline; and identifying preinstalled software on hosted images and installing more at runtime.
On this page10 sections
- The three kinds of runner and where they are registered
- Routing jobs with labels, runner groups and the job queue
- Runner status, updates and automatic removal
- Ephemeral runners, autoscaling and pre- and post-job scripts
- Runner groups: controlling which repositories and workflows can use runners
- Network requirements and proxy configuration for self-hosted runners
- IP allow lists, static IP ranges and private networking
- Troubleshooting self-hosted runners
- Monitoring runners and hosted-runner capacity
- Preinstalled software on hosted images and installing more at runtime
- Choose between standard GitHub-hosted runners, larger runners and self-hosted runners, and register self-hosted runners at the right level with the right configuration flags.
- Route jobs with runs-on labels and runner groups, manage labels on registered runners, and predict what happens to a job that no runner picks up.
- Design autoscaling with ephemeral runners, Actions Runner Controller or workflow_job webhooks, and use pre- and post-job scripts correctly.
- Restrict runner groups by repository and by workflow, including enterprise-shared groups, public repositories and reusable workflows.
- Configure firewall rules, proxies and IP allow lists for runners, and pick between static IP ranges and private networking for larger runners.
- Troubleshoot offline, queued and failing self-hosted runners from the runner's logs, service manager and network check, and monitor hosted-runner capacity.
- Find the preinstalled software of the exact image that ran a job and add tools with setup-* actions, package managers, job containers and custom images.
The three kinds of runner and where they are registered
A runner is the machine that executes a job, and GitHub Actions offers three kinds: standard GitHub-hosted runners (fresh, GitHub-managed virtual machines selected by labels such as ubuntu-latest), larger runners (GitHub-managed machines with more resources and extra features, available on GitHub Team and GitHub Enterprise Cloud plans) and self-hosted runners (machines you provide and maintain, running GitHub's open-source runner application).
| Standard GitHub-hosted | Larger runners | Self-hosted | |
|---|---|---|---|
| Who maintains the machine | GitHub | GitHub | You (OS, patches, tools, cleanup) |
| Clean machine per job | Yes | Yes | Only if you make them ephemeral |
| Runner groups and static IPs | No | Yes (static IP ranges and Azure private networking on Linux and Windows) | Groups yes; the IP is whatever your network gives it |
| Custom image | No | Yes (custom images) | Yes, you build the machine |
| Cost | Per-minute on private repositories; free on public repositories | Always billed per minute, even for public repositories | No Actions minutes; you pay for the infrastructure |
A self-hosted runner is registered at exactly one level: a repository, an organization or an enterprise. Registration runs the runner's config.sh (Linux and macOS) or config.cmd (Windows) with --url and a short-lived registration --token; the token only matters during configuration. Useful flags include --labels (custom labels), --runnergroup, --ephemeral, --disableupdate, --replace (take over the registration of a runner with the same name) and --unattended (no prompts). The runner then usually runs as a service: svc.sh install and svc.sh start on Linux and macOS, or a Windows service.
GitHub recommends self-hosted runners only for private repositories: in a public repository, a fork pull request can run arbitrary code on your machine. Which actions and workflows may run at all is a governance topic covered in the lesson on governing actions, workflows, secrets and variables.
Routing jobs with labels, runner groups and the job queue
GitHub sends a job to an online, idle runner whose labels and runner group match the job's runs-on. When runs-on is an array, the runner must carry every label in it. Every self-hosted runner gets default labels: self-hosted, its operating system (linux, windows, macOS) and its architecture (x64, ARM, ARM64). Custom labels such as gpu narrow the match. Labels are case-insensitive, so Linux matches linux.
jobs:
train:
runs-on: [self-hosted, linux, gpu] # every label must match
sign:
runs-on:
group: build-secure # only runners in this group
labels: [linux, arm64] # ...that also carry these labels
The group key restricts the job to one runner group; combined with labels, the runner must be in the group and have every label. A group name written inside a label array is just a label nobody has. Labels choose which eligible runner takes a job; they are not an access control, because any repository that can reach the runner can name its labels.
Managing labels
Custom labels are set at registration with config.sh --labels. After that, labels are managed on GitHub: open the runner on the Runners settings page and create or assign a label, or use the REST API endpoints that add, set or remove custom labels for repository, organization and enterprise runners. Running the job does not create a label, and editing the runner's .env file does not relabel it.
What happens to a queued job
- If no matching runner is online and idle, the job stays queued (the log shows it waiting for a runner) until one becomes available. If it is still queued after 24 hours, it fails.
- If GitHub assigns the job to a runner that does not pick it up within 60 seconds, the job is re-queued so another matching runner can take it.
- There is no automatic fallback from self-hosted to GitHub-hosted runners. GitHub does not inspect a machine's installed software when matching labels; a
dockerlabel means only that someone applied it.
Runner status, updates and automatic removal
A self-hosted runner shows one of three statuses: Idle (connected, waiting for a job), Active (running a job) or Offline (not connected to GitHub). Offline means the machine is down or has lost its network path to GitHub, or the runner application or service is not running. A runner that never receives jobs because of a label or group mismatch still shows Idle, and a long-running job shows Active.
Updates
The runner application updates itself when a new version is released. For container-based or image-based runners that would re-download every release at start, register with --disableupdate and ship each runner release in your image yourself. The obligation is strict: any release (major, minor or patch) must be applied within 30 days, or the service stops queuing jobs to that runner, and a critical security update can block jobs until it is applied.
Automatic removal
A self-hosted runner that has not connected to GitHub Actions for more than 14 days is removed automatically; an ephemeral runner is removed after 1 day. A removed runner disappears from the Runners list and must be configured again with a new registration token. Removing a runner deliberately is done from its settings page (or the REST API) and, on the machine, with config.sh remove and a removal token.
Ephemeral runners, autoscaling and pre- and post-job scripts
An ephemeral runner takes exactly one job and is then de-registered by the GitHub Actions service, so your automation can wipe or destroy the machine. Register it with config.sh --ephemeral, or create a just-in-time (JIT) runner configuration through the REST API. GitHub recommends ephemeral runners for autoscaling because it guarantees one job per runner and a compromised runner is never handed another job. Autoscaling persistent runners is not recommended.
| Autoscaling option | How it works | When to choose it |
|---|---|---|
| Actions Runner Controller (ARC) with runner scale sets | A Kubernetes operator; a scale set listens for jobs and creates ephemeral runner pods (registered with JIT configuration), scaling down to zero | You run Kubernetes and want the least custom scaling code |
| Runner scale set client | A library over the same scale set APIs for your own platform | You need ARC-style scaling outside Kubernetes |
| workflow_job webhooks | Your service reacts to webhook deliveries and provisions or removes machines | You build your own autoscaler and accept webhook delivery delays |
For a webhook autoscaler, act on the workflow_job event: the queued action means a job needs a runner (start an ephemeral runner with matching labels) and the completed action means that job has finished (remove its machine). in_progress fires after a runner has already taken the job, and workflow_run describes a whole run, so it cannot drive per-job scaling.
Pre- and post-job scripts
A self-hosted runner can run a script automatically before or after every job, without changing any workflow. Set ACTIONS_RUNNER_HOOK_JOB_STARTED (runs after a job is assigned, before it starts) or ACTIONS_RUNNER_HOOK_JOB_COMPLETED (runs at the end of the job) to an absolute path of a script with a .sh (run by Bash with -e, falling back to sh) or .ps1 (run by pwsh, falling back to powershell) extension. Set the variables in the OS environment or in the .env file in the runner directory, and restart the runner after editing .env.
- Exit code 0 lets the job continue; any non-zero exit from the job-started script means the job does not run and is marked failed.
continue-on-errordoes not apply to these scripts. - Output appears in the job log under Set up runner (job started) or Complete runner (job completed).
- The job-completed script runs before the job completes, so it is unsuitable for anything that interrupts the runner, such as deleting the machine. Use ephemeral runners with an external controller that removes the machine on the
workflow_jobcompleted event instead.
Runner groups: controlling which repositories and workflows can use runners
A runner group is the security boundary for self-hosted and larger runners: it collects runners and decides which repositories (and, on GitHub Enterprise Cloud, which workflows) may send jobs to them. Groups contain only self-hosted or larger runners, each runner belongs to exactly one group, and new runners land in the Default group unless registered with --runnergroup or moved.
Repository access
- An organization-level group admits All repositories or Selected repositories. To reserve costly or sensitive runners for two repositories, put the runners in a group limited to those repositories.
- By default only private repositories can use a group. All repositories does not include public ones until Allow public repositories is explicitly turned on, which exposes the runners to fork pull request code.
- An enterprise-level group is first shared with selected organizations by an enterprise owner. Inside each organization, repositories still have no access until an organization owner opens the group (listed as shared by the enterprise) and grants repositories.
Workflow access (GitHub Enterprise Cloud)
A group can also be limited to selected workflows, each given by its full path pinned to a ref, for example octo-org/platform/.github/workflows/deploy.yml@refs/heads/main. Ordinary workflows are pinned to a branch; reusable workflows can be pinned to a branch, tag or full SHA. Only jobs directly defined in a listed workflow can use the group. A reusable workflow's jobs are defined in the called file, so:
- listing a reusable deploy workflow admits its jobs from every caller, and not the callers' own jobs;
- listing only a caller admits the caller's jobs but not the jobs of a reusable workflow it calls, which stay queued until that workflow is listed too.
| Control | What it restricts |
|---|---|
| Runner group repository access | Which repositories' jobs may use the group's runners |
| Runner group workflow access | Which workflow files (at which ref) may use them |
| Labels | Nothing; they select among runners a job may already use |
| Allowed-actions policies | Which actions and reusable workflows may run, not which runners |
For a public open-source repository, the safe and free choice is usually standard GitHub-hosted runners: fork code never touches company machines, and standard runners cost nothing on public repositories. Larger runners also keep fork code off your machines but are always billed. Approval gates for outside contributors or ephemeral self-hosted machines reduce the exposure of self-hosted runners but do not remove it.
Network requirements and proxy configuration for self-hosted runners
A self-hosted runner needs only outbound HTTPS on port 443 to GitHub's documented domains and no inbound rules: the runner application long-polls GitHub for jobs, so all connections start from the runner. The domain list includes github.com, api.github.com, *.actions.githubusercontent.com, codeload.github.com (action downloads), results-receiver.actions.githubusercontent.com and *.blob.core.windows.net (logs, artifacts and caches), plus package and container registry hosts if jobs use them. Allowing github.com alone breaks essential operations.
Proxy servers
The runner honours https_proxy, http_proxy and no_proxy from its own environment, and reads them only when the application starts. That gives three rules:
- A variable exported in someone's shell never reaches a runner service running under a system account. Put the settings in the
.envfile in the runner application directory. - Restart the runner (or its service) after any change.
- On Linux and macOS, use lowercase names; if both
https_proxyandHTTPS_PROXYare set, the lowercase one wins, so adding an uppercase line does nothing. On Windows the names are case-insensitive.
Workflow env: blocks and configuration variables affect job steps, not the runner's own connection to GitHub. On Azure virtual machines, add 168.63.129.16 (the platform host) and 169.254.169.254 (the Instance Metadata Service, which issues managed identity tokens) to no_proxy, because they are reachable only directly from the VM.
Docker needs its own proxy settings
Docker is a separate system. The runner's proxy variables do not make the Docker daemon pull images through the proxy, and containers do not inherit them. For service containers and Docker container actions, configure the daemon's proxy settings and restart Docker, and configure the Docker client so new containers receive proxy variables.
Disabling TLS verification (GITHUB_ACTIONS_RUNNER_TLS_NO_VERIFY) is only a workaround for a TLS-inspecting proxy with an untrusted certificate; it does not set a proxy.
IP allow lists, static IP ranges and private networking
When an organization or enterprise on GitHub Enterprise Cloud enables an IP allow list, GitHub refuses requests from unlisted addresses, including requests made from runners with GITHUB_TOKEN (checkout, API calls, fetching jobs). The fix depends on the runner type:
- Self-hosted runners: add each runner's public egress IP address or range to the organization's or enterprise's IP allow list. Runner group policies,
no_proxyentries and re-registration do not change the address GitHub sees. - GitHub-hosted runners: standard runners use large, changing address pools. GitHub's guidance is to use self-hosted runners or larger runners with static IP ranges (or Azure private networking subnet addresses) and add those addresses. The
actionsranges returned byGET /metaare not a supported way to allowlist hosted runners.
The separate setting that allows IP allow list configuration for installed GitHub Apps adds the apps' own declared addresses; it does not admit runners.
Networking options for GitHub-hosted runners
| Option | Runner type | Best when |
|---|---|---|
| Static public IP ranges | Larger runners, Linux and Windows (not macOS), GitHub Enterprise Cloud | A target's firewall accepts public traffic from listed ranges |
| Azure private networking | Hosted runners deployed into a subnet of your Azure virtual network (not macOS larger runners) | Targets have no public endpoint and you want no gateway or tunnel to operate |
| API gateway with OIDC | Standard runners | You can run a gateway that trusts the job's OIDC token |
| WireGuard overlay | Standard runners | You can run a peer inside the private network |
Static IP ranges help only when the target accepts public traffic. Because macOS larger runners support neither static IPs nor Azure private networking, a macOS build that must reach an IP-restricted service can hand its output as an artifact to a separate job on a Linux larger runner with static ranges.
Troubleshooting self-hosted runners
Troubleshooting a self-hosted runner starts on the machine, because an offline runner cannot run a job that would produce a job log. Work through the network check, the runner's logs and the service manager.
- Network check: run
config.sh --check --url <org or repo URL> --pat <token>(config.cmdon Windows). The token is a classic personal access token with theworkflowscope or a fine-grained token with workflows access. It tests each GitHub service the runner needs and prints PASS or FAIL; details go to log files in the_diagdirectory. - Runner logs:
_diagholdsRunner_logs (the application: connecting, receiving jobs) andWorker_logs (each job's execution). - Linux service: the service is named
actions.runner.<org>-<repo>.<runnerName>.service(the.servicefile in the runner directory has the exact name).sudo journalctl -u <name> -ffollows it in real time (Connected to GitHub, Listening for Jobs, Running job, results);svc.sh statusorsystemctl statusshows whether it runs;systemctl show -p User <name>shows the account it runs as. - macOS and Windows:
svc.sh statuson macOS (launchd); on Windows,Get-Service actions.runner.*and the Event Viewer.
Container jobs
Docker container actions, job containers and service containers need a Linux runner with Docker installed and usable by the runner's account. permission denied on /var/run/docker.sock means that account cannot use the daemon: grant it access, for example through the docker group, and restart the runner. A Docker installed from the snap package can also break container actions, because snap confinement limits the paths the runner shares with containers.
Run-level debug logging (ACTIONS_RUNNER_DEBUG, ACTIONS_STEP_DEBUG) adds detail to job logs once a job runs; reading and debugging job logs is covered in the lesson on troubleshooting workflow runs.
Monitoring runners and hosted-runner capacity
Runner health and capacity are monitored in three places: the Runners settings page, GitHub Actions metrics in Insights, and each larger runner's own settings.
- Runners page: lists self-hosted runners with their status and labels, and for GitHub-hosted runners shows Active jobs (what is running now) and All jobs usage (active jobs against the account's concurrent job maximum). Standard hosted-runner concurrency depends on the plan; once the maximum is reached, new jobs queue.
- GitHub Actions metrics (organization Insights): usage metrics report minutes consumed, while performance metrics report average run time, queue time and failure rates per workflow, job, repository, operating system and runner type. Long waits are diagnosed with performance metrics, not usage metrics.
- Larger runners: each has a configurable maximum concurrency. Jobs that target it queue once that limit is reached, even when the organization is below its overall concurrency maximum, so check this setting first when only one larger runner's jobs wait.
An organization can also disable standard GitHub-hosted runners so that every hosted job goes through larger runners and their runner groups.
Preinstalled software on hosted images and installing more at runtime
GitHub-hosted runner images ship with many preinstalled tools, are updated weekly, and roll out over several days. To know what a specific job had, open its log and expand Set up job > Runner Image: it shows the image name and version and links to Included Software (the exact software list for that image) and the image release. The readme on the main branch of actions/runner-images is updated only after a rollout finishes, so it can describe a different image from the one your job ran.
- To find what changed between a passing and a failing run, compare the Runner Image versions of the two runs and read the image release notes between them. The runner version shown in the same section is the runner application, released in
actions/runner, not the image. - For advance notice of changes (a new default tool version, a label migration), follow the announcements and releases in the
actions/runner-imagesrepository.
Tool cache and setup-* actions
Hosted images carry a tool cache (RUNNER_TOOL_CACHE) with several versions of common runtimes. actions/setup-node, setup-python, setup-java and similar actions resolve the requested version from that cache and add it to PATH, downloading only when it is missing, so a weekly image change cannot move your version. Pinning the OS label (for example ubuntu-24.04) does not freeze tool versions.
check-latest: truechecks for the newest release matching the version spec and downloads it if the cached one is older: newer patches, slightly slower setup.cache: npm(oryarn,pnpm;pipfor setup-python) caches the package manager's download cache, keyed on the lock file, without a separateactions/cachestep.- On self-hosted runners, setup actions install into the runner's tool cache. To place it elsewhere, set
AGENT_TOOLSDIRECTORYin the runner's environment (the.envfile for a service), give the runner's account write access to the folder, and restart the runner. Running the service as root would also work but grants far more privilege than needed.
Package managers, containers and custom images
- Linux: hosted runners have passwordless
sudo. Runsudo apt-get updatebeforesudo apt-get install -y <package>: a stale index causes failures, and without-yapt waits for a confirmation nobody gives.apt-get upgradeonly updates installed packages. - macOS:
brew install. Windows:choco install(hosted Windows runners run as administrator). - Job container: the job's
container:key runs every step inside an image you publish, so a prebuilt toolchain is always present.services:starts side containers that steps reach over the network, andruns-ononly chooses the runner. - Custom images for larger runners: create an image-generation larger runner, run a job on it that includes the
snapshotkeyword with the image name, and each successful run produces a new image version. Larger runners created from the custom image start with the tools installed; GitHub recommends regenerating weekly so dependencies and security patches stay current. - Self-hosted runners: you build the machine or container image yourself, so preinstall what jobs need there.
Caching dependencies across runs with actions/cache and artifact retention are covered in the lesson on optimizing workflow performance and cost.
Tip. Questions on this topic are usually scenarios with one deciding constraint: a job that must reach exactly one kind of machine, runners that must be reserved for certain repositories or workflows, a firewall or IP allow list that must stay in place, a requirement for clean machines or no servers to maintain, or a tool version that must not drift. Options are concrete artefacts such as runs-on values, config.sh flags, .env lines, service commands, runner group settings and setup-action inputs, and the wrong ones are real features used in the wrong place: labels where a group is needed, repository access where workflow access is needed, usage metrics where performance metrics are needed, the job-completed hook where an ephemeral runner is needed, or static IP ranges where private networking is needed. Expect troubleshooting items that start from a symptom (Offline, waiting for a runner, permission denied on the Docker socket, a build broken by an image update) and ask for the cause or the evidence to collect.
- runs-on as an array needs a runner with every label; labels are case-insensitive and choose among runners, they never restrict access.
- runs-on with group plus labels requires the runner to be in that group and carry every label.
- A job with no matching runner stays queued and fails after 24 hours; an assigned job not picked up within 60 seconds is re-queued.
- Ephemeral runners (--ephemeral or JIT) take one job; autoscale with ARC runner scale sets or workflow_job queued/completed webhooks, never with the job-completed script.
- --disableupdate obliges you to apply every runner release within 30 days; runners offline more than 14 days (ephemeral: 1 day) are removed.
- Runner groups are the access boundary: repository access, public repositories off by default, enterprise groups need an org owner's grant, workflow access admits only jobs directly defined in listed workflows.
- Self-hosted runners need only outbound HTTPS 443 to GitHub's domains; proxy variables go in the runner's .env, lowercase wins on Linux and macOS, and a restart is required.
- With an IP allow list, add self-hosted egress IPs or use larger runners with static IP ranges; GET /meta ranges are not a supported allowlist, and macOS larger runners get neither static IPs nor Azure private networking.
- Set up job > Runner Image shows the exact image and its Included Software; setup-* actions pin tool versions from the tool cache.
- Install extra software with sudo apt-get update then install -y, a job container, or a custom image built with the snapshot keyword.
Frequently asked questions
Do self-hosted GitHub Actions runners need inbound firewall rules?
No. A self-hosted runner only needs outbound HTTPS on port 443 to GitHub's documented domains, such as api.github.com, *.actions.githubusercontent.com, codeload.github.com and the blob storage hosts used for logs, artifacts and caches. The runner long-polls GitHub for jobs, so every connection starts from the runner and no inbound rule is required.
Why is my GitHub Actions job stuck waiting for a runner?
A job waits when no online, idle runner matches every label in runs-on and is in a runner group the repository (and, on GitHub Enterprise Cloud, the workflow) may use. Common causes are a missing custom label, all matching runners offline or busy, a group that excludes the repository or public repositories, or a reached concurrency limit. A job still queued after 24 hours fails.
What is the difference between a runner label and a runner group?
A runner group is an access boundary that decides which repositories and workflows may send jobs to its self-hosted or larger runners, and each runner belongs to exactly one group. A label is a tag that runs-on uses to pick a suitable runner among those the job can already use. Labels restrict nothing, so access is always controlled with groups.
How do I configure a proxy for a self-hosted runner that runs as a service?
Put https_proxy, http_proxy and no_proxy in the .env file in the runner application directory and restart the runner service, because the runner reads these variables only when it starts. On Linux and macOS use lowercase names, since the lowercase form wins when both cases exist. Docker needs its own proxy configuration for image pulls and containers.
Can GitHub-hosted runners work with an organization IP allow list?
Standard GitHub-hosted runners use large, changing address pools that GitHub does not support allowlisting. With an IP allow list on GitHub Enterprise Cloud, use larger runners with static IP ranges (Linux or Windows) or Azure private networking and add their addresses, or use self-hosted runners and add their egress IP addresses.
How can I see which software versions were installed on the runner that ran my job?
Open the job log, expand Set up job and then Runner Image. It shows the image name and version and links to Included Software, the exact software list of that image, and to the image release notes. The actions/runner-images readme on the main branch reflects only the latest fully rolled-out image.
What does the ACTIONS_RUNNER_HOOK_JOB_STARTED variable do?
It points a self-hosted runner at a script, by absolute path with a .sh or .ps1 extension, that runs after a job is assigned and before it starts, without any workflow change. A non-zero exit fails the job, and the output appears under Set up runner in the job log. ACTIONS_RUNNER_HOOK_JOB_COMPLETED is the matching end-of-job script.
When should I use ephemeral self-hosted runners?
Use ephemeral runners whenever runners autoscale or each job needs a clean machine. An ephemeral runner, registered with --ephemeral or as a just-in-time runner through the REST API, is de-registered after one job, so the machine can be destroyed and a compromised runner never receives another job. Actions Runner Controller creates them automatically.
Source
This lesson covers the "Manage GitHub Actions for the enterprise" domain of the official GH-200 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.