Self-hosted GitHub Actions runners: setup, autoscaling and ephemeral jobs

A self-hosted GitHub Actions runner is the open source actions/runner agent running on a machine you control. You register it at repository, organization or enterprise level with a one-hour token, give it labels, and point workflows at it with runs-on. For production, register runners as ephemeral (one job, then gone) and scale them with the queue, either through the JIT API or Actions Runner Controller on Kubernetes.

Use cases for a self-hosted runner

GitHub-hosted runners cover most builds. You self-host when you need hardware GitHub does not rent you (a GPU, 64 GB of RAM, a specific arm64 board), access to a private network (a database behind a VPN, an on-prem artifact store), a pre-warmed cache of several gigabytes, or lower per-minute cost on long jobs. Self-hosted runner minutes do not count against your GitHub Actions minutes quota. You pay for the machine instead, plus the time you spend keeping it patched.

In December 2025 GitHub announced a $0.002 per minute platform charge for self-hosted runner jobs, then postponed it two days later to re-evaluate. Check the Actions billing page before you budget.

Prerequisites

  • A Linux, Windows or macOS machine on x64 or ARM64. This guide uses Ubuntu.
  • Outbound HTTPS on port 443 to GitHub's domains. The runner polls GitHub, so you open no inbound ports.
  • Repository owner rights for a repo-level runner, organization owner rights for an org-level runner, or enterprise owner rights for an enterprise-level runner.
  • Runner version 2.329.0 or newer. Since 16 March 2026, GitHub.com blocks registration from older versions.

Install and register a runner on Linux

Pick the scope first. A repository runner serves one repo. An organization runner serves any repo the runner group allows. An enterprise runner serves organizations across a GitHub Enterprise Cloud account. The commands are the same; the URL and the token change.

Open Settings > Actions > Runners > New self-hosted runner on the repo or org. GitHub shows a registration token that expires after one hour. For automation, request one through the REST API:

# organization runner
curl -X POST \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/orgs/my-org/actions/runners/registration-token

# repository runner: POST /repos/{owner}/{repo}/actions/runners/registration-token

Download and unpack the runner. Check the releases page for the current version (v2.337.0 at the time of writing):

RUNNER_VERSION=2.337.0
mkdir actions-runner && cd actions-runner
curl -o runner.tar.gz -L \
  https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
tar xzf runner.tar.gz
sudo ./bin/installdependencies.sh

For ARM64, swap linux-x64 for linux-arm64. Then register the runner. --url takes the repo, org or enterprise URL:

./config.sh \
  --url https://github.com/my-org \
  --token AABBCCDD... \
  --name build-01 \
  --runnergroup default \
  --labels gpu,ubuntu-24 \
  --work _work \
  --unattended

--unattended skips the interactive prompts. --replace overwrites an existing runner with the same name, which helps when you rebuild a VM. Run it in the foreground with ./run.sh, or install it as a systemd service:

sudo ./svc.sh install    # optional: sudo ./svc.sh install ci-user
sudo ./svc.sh start
sudo ./svc.sh status

The runner shows as Idle in the runners list once it connects.

Labels, runner groups and runs-on

GitHub adds default labels on registration: self-hosted, an OS label (linux, windows or macOS) and an architecture label (x64, ARM or ARM64). You add custom labels with --labels or in the UI, and --no-default-labels drops the defaults. A job lands on a runner that carries every label in its runs-on array:

jobs:
  train:
    runs-on: [self-hosted, linux, x64, gpu]
    steps:
      - uses: actions/checkout@v4
      - run: python train.py

Runner groups control which repositories may use a set of runners. Every organization has a default group; organizations on GitHub Team or Enterprise can create more and restrict each one to selected repositories. Target a group, or a group plus a label:

runs-on:
  group: build-runners
  labels: [linux, gpu]

Keep a separate group for runners that hold deploy credentials and limit it to the repositories that deploy.

Ephemeral and just-in-time runners

A persistent runner keeps its work directory, Docker images, caches and anything a job left in /tmp. That speeds up builds and leaks state between jobs. Register with --ephemeral and the runner takes one job, then GitHub removes the registration:

./config.sh --url https://github.com/my-org --token AABBCCDD... --ephemeral --unattended
./run.sh

Pair this with a fresh VM or container per job and throw the machine away when run.sh exits. Your autoscaler listens for workflow_job webhooks (action queued), boots a machine, registers it, and deletes it after the job.

Just-in-time (JIT) runners remove the config.sh step. Your orchestrator calls the API with a name, runner group ID and labels, and receives an encoded_jit_config blob that registers one single-use runner:

curl -X POST \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/orgs/my-org/actions/runners/generate-jitconfig \
  -d '{"name":"jit-4711","runner_group_id":1,"labels":["self-hosted","linux","x64"],"work_folder":"_work"}'

# on the fresh VM
./run.sh --jitconfig "$ENCODED_JIT_CONFIG"

The repository variant is POST /repos/{owner}/{repo}/actions/runners/generate-jitconfig. Treat the encoded config as a secret: it is a bearer credential until the runner picks up its job. Do not write it to logs.

Autoscaling on Kubernetes with ARC

Actions Runner Controller (ARC) is GitHub's supported Kubernetes operator. In runner scale set mode it runs a listener that talks to GitHub, creates an ephemeral runner pod per job and deletes it afterwards. You install two Helm charts from GHCR, the controller and one or more scale sets:

helm install arc \
  --namespace arc-systems --create-namespace \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

helm install arc-runner-set \
  --namespace arc-runners --create-namespace \
  --set githubConfigUrl="https://github.com/my-org" \
  --set githubConfigSecret.github_token="$GITHUB_PAT" \
  --set minRunners=0 --set maxRunners=20 \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

The Helm release name (arc-runner-set) becomes the value you put in runs-on:

jobs:
  build:
    runs-on: arc-runner-set

For production, authenticate with a GitHub App (github_app_id, github_app_installation_id, github_app_private_key) in place of a personal access token. Set containerMode.type to dind if jobs need Docker, or kubernetes to run job containers as separate pods. ARC 0.15.0 shipped on 1 October 2026 with in-place patch upgrades and configurable controller concurrency. The ARC quickstart lists the full values.

Outside Kubernetes you build the scaler yourself: a webhook receiver, a VM API call, and the JIT endpoint above. Several open source projects do this for AWS and GCP.

Security: public repos, forks and secrets

GitHub's guidance says self-hosted runners should almost never serve public repositories. Anyone can fork a public repo and open a pull request that edits the workflow, and that code runs on your machine with whatever network access and leftover files the runner has. On a persistent runner, an attacker can plant a backdoor that later jobs inherit.

  • Use self-hosted runners with private repositories. If you must serve a public repo, use ephemeral runners on throwaway VMs with no internal network access.
  • Under Settings > Actions > General, set fork PR approval to Require approval for all external contributors.
  • Scope runner groups to named repositories. Put production deploy runners in their own group.
  • Run the service as an unprivileged user. Avoid mounting the host Docker socket on shared machines.
  • Prefer OIDC to long-lived cloud keys stored on the runner host.

Runner updates and version enforcement

By default the runner updates itself when GitHub releases a new version. Ephemeral fleets built from images should pass --disableupdate and bake the new version into the image. If you disable updates, you must ship a new version within 30 days of each release, or GitHub stops queuing jobs to the runner. GitHub.com also refuses config.sh registration from runners older than v2.329.0. ARC users get updates by bumping the runner image in the scale set values.

Troubleshooting

  • Job stuck on "Waiting for a runner to pick up this job". No online runner has every label in runs-on, or the runner group excludes the repository. Compare labels letter by letter.
  • config.sh fails with an authentication error. The registration token expired (one hour) or you used a token for the wrong scope. Generate a fresh one for the same URL you pass to --url.
  • Registration rejected because of the runner version. Download v2.329.0 or newer and rebuild your images.
  • Missing library errors on first start. Run sudo ./bin/installdependencies.sh.
  • Disk full after a few weeks. Persistent runners accumulate Docker layers and _work checkouts. Move to ephemeral runners, or add a cron job running docker system prune.

Cost trade-offs

An always-on 16 vCPU VM costs the same at 3 a.m. as at peak, so a single big runner looks cheap only while your team keeps it busy. Autoscaling fixes utilisation and adds engineering work: image builds, a scaler, monitoring, runner version bumps every month, and on-call when the scaler breaks. Count that time before comparing per-minute prices. If you also run GitLab, the same reasoning applies to GitLab custom runners.

FAQ

Do self-hosted runners use GitHub Actions minutes?

No. Jobs on self-hosted runners do not consume your included minutes. You pay for the machines you run. GitHub postponed a planned per-minute platform charge for self-hosted jobs in December 2025 and has not set a new date as of this writing.

Can one self-hosted runner serve multiple repositories?

Yes. Register it at organization or enterprise level and use runner groups to choose which repositories may send jobs to it.

Can a self-hosted runner run more than one job at a time?

One runner process runs one job. For parallel jobs, start several runner processes (each in its own directory) or run more machines.

Is it safe to use a self-hosted runner on a public repository?

GitHub advises against it. Fork pull requests can run arbitrary code on the machine. If you do it, use ephemeral runners on isolated, disposable VMs and require approval for outside contributors.

Ephemeral runner vs JIT runner: which should I use?

Both run one job. --ephemeral still uses config.sh and a registration token; JIT gives you a single pre-registered config from one API call, which suits autoscalers.

Managed runners with cirunner.dev

cirunner.dev runs the ephemeral pattern from this guide for you. It provisions a fresh VM per job, registers it with GitHub Actions using a token you provide, scales with the queue and destroys the machine after the job. You choose CPU, RAM, x86_64 or arm64, region, base image and an optional GPU, and you pay per compute minute.

The service is in early access with GitHub Actions and GitLab CI as launch platforms. Request access here.

Skip the runner fleet

cirunner.dev boots a fresh VM for every GitHub Actions job, registers it, and destroys it when the job ends. GitHub Actions and GitLab CI are our launch platforms.

Join early access