← Blog
Engineering

GitHub self-hosted runner: Linux setup, Docker and Kubernetes

Yura Dubinin
Yura Dubinin

A GitHub self-hosted runner runs GitHub Actions jobs on a machine you supply, using GitHub's open-source actions/runner agent. The runner connects outbound to GitHub over HTTPS to receive work. As of 4 November 2026, GitHub's billing docs say self-hosted runner compute is free; you pay for the machine and its upkeep. GitHub runner reference, GitHub Actions billing.

We build Lizard, which sells GitHub Actions runners; prices checked on 4 November 2026.

The shortest path is a Linux VM, a registration token from your repository's settings, and a workflow with matching runner labels. Start with a private test repository. For a fleet, choose ephemeral VMs, Kubernetes with ARC, or the AWS Terraform module. For existing runners, jump to security, troubleshooting or machine costs.

Self-hosted runner vs GitHub-hosted runner

GitHub provisions and maintains a fresh environment for each hosted job. With a self-hosted runner, you supply, patch and secure the machine.

What you ownSelf-hostedGitHub-hosted
Compute billYour machine or provider bill; no GitHub self-hosted compute charge (4 Nov 2026)Standard public-repository jobs are free; private-repository usage draws from your plan's allowance before overage
HardwareChoose your own CPU, memory, GPU and networkChoose from GitHub's runner types
Environment between jobsCan persist; you must arrange a clean machine per jobGitHub provisions a fresh environment per job
MaintenanceYou patch the OS, tools and images; the runner agent can update itselfGitHub maintains its standard images and runner infrastructure
Security boundaryYou control machine isolation, credentials and network accessGitHub operates the execution environment; you still secure your workflows
Installed softwareInstall what your jobs needStandard images include tools; check the image's software list

Sources: GitHub's self-hosted runner overview, hosted runner overview and billing docs.

Keep GitHub-hosted runners if your open-source project already runs free, or your team does not want to maintain build machines. Self-hosting makes sense when you need particular hardware, access to a private network, or enough steady work to justify operating the machines.

Which setup fits?

SetupUse it whenUpkeep you own
Single VMYou need a first runner for trusted jobsOS, tools, service and disk cleanup
Ephemeral VM or JITEach job needs a clean machineProvisioning, images, logs and VM destruction
DockerYou want a repeatable agent imageDocker host, image updates and job isolation
ARC on KubernetesYour team already operates KubernetesCluster capacity, charts, secrets and images
Terraform on AWSRunners must stay in your AWS accountAWS resources, IAM, images and scaling failures
Managed runners such as LizardYou want a provider to operate runner machinesWorkflows, permissions and provider limits

How do you set up a GitHub Actions self-hosted runner on Linux?

To add a GitHub Actions self-hosted runner:

  1. Open Settings → Actions → Runners → New self-hosted runner in your repository.
  2. Download actions/runner and check its SHA-256 checksum.
  3. Register it with ./config.sh, the repository URL and the page's registration token.
  4. Start ./run.sh and confirm it is listening.
  5. Send a job with runs-on: [self-hosted, linux, x64, linux-build], matching the custom label below.

Use an Ubuntu 24.04 or Debian 12 x64 VM with systemd. Log in as a normal, non-root user who can use sudo. You need a private GitHub repository you administer, outbound internet access, and enough disk and memory for your build. GitHub's runner requirements support both operating systems and require outbound HTTPS on port 443. The runner does not need an inbound webhook port.

On 4 November 2026, we tested these steps on a fresh Debian 12 x64 VM with systemd and actions/runner v2.338.2: download and checksum, registration to a private test repository, the smoke-test workflow, service installation, removal and a repository-level JIT runner. The outputs below are excerpts from that test; our test login was builder.

Download the runner and verify it

In the repository, open Settings → Actions → Runners → New self-hosted runner, then select Linux and x64. For an organization runner, use the organization's settings and an organization owner account. Enterprise administrators can also add runners at enterprise scope. GitHub's setup instructions explain each scope.

Run this in Bash as your non-root login. It gets the current release and its SHA-256 digest from GitHub's release API, then checks the archive before extracting it:

sudo apt-get update
sudo apt-get install -y ca-certificates curl jq
mkdir actions-runner
cd actions-runner
curl -fsSL https://api.github.com/repos/actions/runner/releases/latest -o release.json
RUNNER_VERSION=$(jq -er '.tag_name | ltrimstr("v")' release.json)
RUNNER_TARBALL="actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
RUNNER_URL=$(jq -er --arg name "$RUNNER_TARBALL" \
  '.assets[] | select(.name == $name) | .browser_download_url' release.json)
RUNNER_SHA=$(jq -er --arg name "$RUNNER_TARBALL" \
  '.assets[] | select(.name == $name) | .digest | select(startswith("sha256:")) | ltrimstr("sha256:")' release.json)
curl -fsSL "$RUNNER_URL" -o "$RUNNER_TARBALL"
printf '%s  %s\n' "$RUNNER_SHA" "$RUNNER_TARBALL" | sha256sum -c - && \
  tar xzf "$RUNNER_TARBALL" && \
  sudo ./bin/installdependencies.sh && \
  ./config.sh --help

Expected result: actions-runner-linux-x64-2.338.2.tar.gz: OK. Extraction runs only if the checksum passes; stop if it fails or GitHub supplies no digest. Releases roll out progressively, so use the version and checksum on your repository’s setup page if required. GitHub runner releases.

On Debian 12, installdependencies.sh printed E: Unable to locate package libicu80 and similar lines while trying ICU versions, then found libicu72. Those fallback attempts were expected in our test; check that the script finishes successfully.

Register it and start listening

Copy the registration token from the setup page. GitHub says this token expires after one hour. It is a runner registration token, not a personal access token. Replace YOUR_OWNER/YOUR_REPO below with the repository path; use https://github.com/YOUR_ORG for organization scope.

read -rsp 'Registration token: ' RUNNER_TOKEN
printf '\n'
./config.sh --unattended \
  --url https://github.com/YOUR_OWNER/YOUR_REPO \
  --token "$RUNNER_TOKEN" \
  --name linux-build-01 \
  --labels linux-build
unset RUNNER_TOKEN
./run.sh

Expected result: √ Connected to GitHub, √ Runner successfully added, √ Settings Saved., then Listening for Jobs after ./run.sh. In Settings → Actions → Runners, look for Idle when connected and ready. Leave the terminal running for the first job. Registration, runner status.

Send it a job

Commit this file as .github/workflows/runner-check.yml on your repository's default branch:

name: Check self-hosted runner
on:
  workflow_dispatch:
permissions:
  contents: read
jobs:
  check:
    runs-on: [self-hosted, linux, x64, linux-build]
    steps:
      - name: Show the execution environment
        run: |
          echo "Runner accepted the job"
          uname -s -m
          whoami

Open Actions → Check self-hosted runner → Run workflow. Expected result from our test: Runner accepted the job / Linux x86_64 / builder. Your login name may differ. This smoke test needs no checkout or toolchain; add those for your build. If it stays queued, see why the job is waiting.

GitHub matches all labels in the runs-on list. The runner gets self-hosted, linux and x64 from registration; linux-build is our example custom label. A label describes a runner but does not install software or grant repository access. GitHub workflow routing.

Keep it running as a service

After the test finishes, press Ctrl-C and confirm the foreground ./run.sh listener has exited before starting the service. Otherwise, the journal can show A session for this runner already exists. and Conflict, while the old listener takes jobs. We hit this in our test. From the runner directory, under the same non-root login:

sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status

Expected result: Active: active (running) from svc.sh status. GitHub creates svc.sh after registration; on systemd these commands keep the runner running through logout and reboot. GitHub service setup.

Remove a runner

In Settings → Actions → Runners, select the runner and click Remove to get a removal token. From its directory, stop and uninstall the service, then remove the registration:

sudo ./svc.sh stop
sudo ./svc.sh uninstall
read -rsp 'Removal token: ' REMOVAL_TOKEN
printf '\n'
./config.sh remove --token "$REMOVAL_TOKEN"
unset REMOVAL_TOKEN

Expected result: √ Runner removed successfully, √ Removed .credentials, √ Removed .runner. GitHub's runner removal instructions cover the UI; automation can request a repository removal token.

How do runner groups differ from labels?

A GitHub Actions runner group controls which repositories can use a set of runners. Labels help select a runner within that allowed set. For organization runners, open Settings → Actions → Runner groups, select a group, and restrict Repository access to the repositories that need it. GitHub runner group controls.

For an organization runner, create a group named trusted-builds, then register with --url https://github.com/YOUR_ORG --runnergroup trusted-builds. The group must exist first; this flag does not apply to the repository-scoped setup above. A job can require both group and label:

runs-on:
  group: trusted-builds
  labels: linux-build

Keep release jobs away from runners used by less trusted repositories. Groups reduce who can schedule work; they do not isolate processes or make shared disks safe. GitHub's routing docs require both group access and matching labels.

How do ephemeral and just-in-time runners work?

An ephemeral runner accepts one job before GitHub deregisters it; enable this with --ephemeral. Your infrastructure must still destroy the VM or wipe its disks. GitHub recommends ephemeral runners for autoscaling and says to preserve their logs externally before production use. GitHub autoscaling reference.

If you kept an earlier runner with the same linux-build label, stop its service with sudo ./svc.sh stop, or use a different label in both the new runner and workflow. Otherwise, GitHub may send the test job to the earlier runner. On a fresh VM and unconfigured runner directory, use a new registration token:

read -rsp 'Registration token: ' RUNNER_TOKEN
printf '\n'
./config.sh --unattended \
  --url https://github.com/YOUR_OWNER/YOUR_REPO \
  --token "$RUNNER_TOKEN" \
  --name "ephemeral-$(hostname)" \
  --labels linux-build \
  --ephemeral
unset RUNNER_TOKEN
./run.sh

Destroy the machine when the listener exits, including after failures. Also handle cancellation and machines that never receive a job. Re-registering the same dirty VM does not create a clean environment.

Generate a just-in-time configuration through the API

A JIT runner receives its configuration at startup and can run at most one job. A provisioner can obtain that configuration without putting a long-lived GitHub API credential on the worker. GitHub still warns that reusing the underlying hardware requires a clean environment. GitHub JIT security guidance.

For this organization-level example, use an API token authorized for the organization with Self-hosted runners: write permission. An organization administrator can use a fine-grained token for a test; use a GitHub App installation token in automation. Get the numeric group ID from GitHub's runner groups API, and restrict that group's repository access first.

In Bash, replace the organization and group placeholders. Run it from the runner directory on a fresh machine (repeat the download block first), only for a trusted test; in production, run the API request in the provisioner and deliver only the returned configuration to the worker.

ORG='YOUR_ORG'
RUNNER_GROUP_ID='REPLACE_WITH_NUMERIC_GROUP_ID'
read -rsp 'GitHub API token: ' GH_API_TOKEN
printf '\n'
JIT_CONFIG=$(
  jq -n --arg name "jit-$(date +%s)" --argjson group "$RUNNER_GROUP_ID" \
    '{name: $name, runner_group_id: $group,
      labels: ["self-hosted", "linux", "x64", "linux-build"],
      work_folder: "_work"}' |
  curl -fsSL -X POST \
    -H 'Accept: application/vnd.github+json' \
    -H "Authorization: Bearer $GH_API_TOKEN" \
    -H 'X-GitHub-Api-Version: 2026-03-10' \
    --data-binary @- \
    "https://api.github.com/orgs/$ORG/actions/runners/generate-jitconfig" |
  jq -er '.encoded_jit_config'
)
unset GH_API_TOKEN
[ -n "$JIT_CONFIG" ] && ./run.sh --jitconfig "$JIT_CONFIG"
unset JIT_CONFIG

GitHub documents this organization JIT request and a repository-level JIT endpoint for single-repository tests; our test used the latter with runner_group_id: 1. The --jitconfig flag works even though ./run.sh --help does not list it. Treat the encoded configuration as a secret; routing still depends on scope, groups and labels.

Expected result after the JIT job: Job check completed with result: Succeeded, √ Removed .credentials, √ Removed .runner, then Runner listener exit with 0 return code, stop the service, no retry needed. and Exiting runner....

How do you run a self-hosted GitHub runner in Docker?

GitHub’s maintained ghcr.io/actions/actions-runner image runs the agent in a container. This example uses tag 2.338.2, the latest runner release on 4 November 2026; we checked its Linux x64 manifest but have not run a Docker job with it.

Install Docker Engine first using Docker’s Ubuntu instructions or Debian instructions. The command uses sudo; Docker’s alternative docker-group setup grants root-level access.

Obtain a fresh one-job configuration from GitHub’s generate-jitconfig API using the JIT request, but skip its local runner-start and unset JIT_CONFIG lines. With the returned configuration in JIT_CONFIG, run:

sudo docker run --rm --init --platform linux/amd64 \
  --entrypoint /home/runner/run.sh \
  ghcr.io/actions/actions-runner:2.338.2 \
  --jitconfig "$JIT_CONFIG"
unset JIT_CONFIG

Send the smoke-test workflow to the container’s label. It runs as the image’s runner user, attaches no persistent workspace and is removed on exit. GitHub’s image source lists its tools; add build dependencies to a derived image.

Running the agent in Docker is different from using a workflow's container: setting to run job steps in Docker. GitHub requires Linux and a working Docker installation for Docker container actions and service containers. The command above does not configure a Docker daemon for nested container jobs. GitHub container jobs.

Mounting the host's Docker socket lets job code control that daemon, which can expose the host filesystem. Docker-in-Docker often requires a privileged container and needs its own disk, networking and cleanup plan. Neither approach makes an ordinary shared Docker host safe for hostile pull requests. Use a disposable VM boundary for untrusted work, and read Docker's daemon security guidance.

How do you autoscale on Kubernetes with Actions Runner Controller?

Actions Runner Controller (ARC) is an open-source Kubernetes controller for self-hosted GitHub Actions runners, maintained by GitHub and community contributors. It creates runner pods for queued jobs and scales the fleet within your minRunners and maxRunners settings, down to zero if configured. Use it when your team already operates Kubernetes; GitHub’s ARC quickstart requires a cluster and Helm 3. ARC architecture.

Install the controller in its own namespace:

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

For this organization-scoped example, create and install a GitHub App with organization Self-hosted runners: read and write and repository Metadata: read-only. Save its private key locally as github-app.pem. Repository-scoped registration also requires Administration: read and write. Follow GitHub's ARC authentication instructions.

Replace the App and installation IDs, then create the secret in the runner namespace:

kubectl create secret generic arc-github-app \
  --namespace arc-runners \
  --from-literal=github_app_id=YOUR_APP_ID \
  --from-literal=github_app_installation_id=YOUR_INSTALLATION_ID \
  --from-file=github_app_private_key=./github-app.pem

Save this as runner-values.yaml. The example chooses zero idle runners and a maximum of four runners; those are your budget settings, not ARC product limits.

githubConfigUrl: https://github.com/YOUR_ORG
githubConfigSecret: arc-github-app
runnerScaleSetName: arc-linux
minRunners: 0
maxRunners: 4

Install the runner scale set and inspect the controller:

helm install arc-linux \
  --namespace arc-runners \
  --values runner-values.yaml \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
helm list -A
kubectl get pods -n arc-systems

In the earlier smoke-test workflow, replace the label list with:

runs-on: arc-linux

Trigger the workflow and watch the runner namespace:

kubectl get pods -n arc-runners -w

GitHub's expected result is a running controller and a runner pod created for the job. Scaling runner pods to zero does not remove your cluster's standing costs. These chart names and values were checked against GitHub's deployment docs and chart values; we did not deploy a cluster for this article.

Before production, pin tested chart and image versions, collect controller and runner logs, and set pod resource requests that fit your builds. Restrict access to the GitHub App secret. For container jobs, choose ARC's documented Docker-in-Docker or Kubernetes container mode and review its privileges. The minimal values above do not configure either mode.

How do you autoscale self-hosted runners on AWS?

terraform-aws-github-runner is an open-source Terraform module that autoscales self-hosted runners on EC2 in your AWS account, without Kubernetes. Its webhook-based setup receives queued-job events through a GitHub App and uses Lambda and SQS to manage demand. It supports ephemeral runners and Spot instances. Project architecture.

Use the project's example for your setup and pin the module version. Supply your VPC, subnets, GitHub App and runner image, then review the IAM permissions and Terraform plan before applying it. Plan for interrupted Spot jobs, failed provisioning and orphaned instances. Include EC2, disks, logs, network services and the controller's AWS resources in the bill.

Choose this setup if policy requires runners in your AWS account. Your team still patches images and handles failures. We have not deployed this Terraform setup for the article.

Can you safely use self-hosted runners for public repositories?

Not by default. GitHub recommends self-hosted runners only for private repositories because a fork pull request can execute attacker-controlled code on the machine. Private repositories also need care: someone allowed to fork and open a pull request may be able to run code in your runner environment. GitHub security guidance.

For public contributions, GitHub-hosted runners are the safer starting point. Do not attach the persistent VM from this tutorial to a public repository. If you must self-host untrusted code, use an isolated, disposable environment per job with restricted credentials and network access. An ephemeral registration alone is not that boundary.

  • Keep SSH keys, cloud administrator credentials and production data off runner disks. Use short-lived, narrowly scoped credentials where possible.
  • Separate repositories with different trust levels through runner groups and separate execution environments. A custom label is not access control.
  • Do not check out and execute an untrusted pull request under a privileged pull_request_target workflow. Review event permissions and fork-run approval policies.
  • Restrict access to internal services and cloud metadata endpoints. Allow the GitHub endpoints your jobs need, plus their dependency registries; outbound-only connectivity still allows data theft.
  • Treat caches as shared state. Do not let untrusted jobs write data that a privileged build will later execute.

These controls follow GitHub's secure-use guidance. Its communication reference lists the domains needed for runner updates, actions, logs, artifacts and caches.

Keep the runner and its image up to date

GitHub's runner reference requires runners with automatic updates disabled to update within 30 days of a new release, including patch releases. GitHub stops queueing work to stale runners; a required critical security update can block jobs sooner. GitHub update policy.

The runner normally updates itself. Use --disableupdate only if your team rebuilds runner images within that deadline. Agent updates do not patch Ubuntu, Docker or your build tools. Replace or drain machines for maintenance without interrupting active jobs.

Why is the runner offline or the job waiting?

A job waits when no online, idle runner matches its labels and allowed scope or group. An offline runner has stopped listening or cannot reach GitHub. GitHub’s routing reference says queued jobs fail after more than 24 hours.

SymptomCheck firstWhat to do
Runner is OfflineMachine state, listener or service, outbound HTTPS, proxy and certificatesStart the service; inspect its logs and GitHub connectivity
Job says “Waiting for a runner”Every runs-on label, runner group access, runner scope and whether matching runners are busyCorrect the selector or group policy; check autoscaler and cloud capacity
Service reports ConflictA foreground listener is still running for the same registrationConfirm ./run.sh has exited before starting the service
Registration failsToken expiry, target URL and organization policyGet a fresh registration token for the correct scope
Permission deniedService user, workspace ownership and Docker accessFix the intended user's permissions; avoid switching the entire runner to root
No space leftBuild output, Docker layers, caches and retained logsInspect disk use and remove disposable data after draining the runner
Jobs stopped arriving after an image went staleRunner version and update logsRebuild with a current runner and replace the stale instances

GitHub’s troubleshooting reference covers runner status, logs, connectivity and Docker permissions.

On the systemd machine, inspect the last 100 journal lines, for example, then the diagnostic files and disk use:

sudo ./svc.sh status
sudo journalctl -u "$(cat .service)" -n 100 --no-pager
ls -lt _diag/
df -h

In _diag, Runner_ files describe the listener and Worker_ files describe individual jobs. Preserve them before destroying ephemeral runners. If the job never started, read the newest Runner_ log; if it started and failed, read its Worker_ log and job output.

What does a self-hosted runner machine cost?

Budget for the machine and its upkeep: image patches, disk cleanup, monitoring and failed jobs.

As of 4 November 2026, GitHub’s billing docs still list self-hosted compute as free after the proposed fee was postponed; see the full GitHub Actions price list for fees and included usage.

Example: one always-on Linux VM

On 4 November 2026, DigitalOcean's Basic Regular Droplet price table listed 2 shared vCPUs, 4 GiB RAM, 80 GiB SSD and 4,000 GiB transfer at $24 per month. Its billing docs say bundled Droplets keep billing while powered off; destroy the Droplet to stop its charge.

Assume the VM runs all month in NYC3, where Basic Droplets are available, handles your private repository’s builds one at a time, and has no credits or usage beyond the bundle. This budget example excludes tax, backups, snapshots, extra storage or traffic, GitHub charges and operator time; it makes no performance claim.

The VM line stays $24 whether it handles occasional jobs or stays busy under those assumptions. Assign someone to maintain it before relying on it for releases.

Compare complete monthly bills using recent job history, including idle capacity and retries, and apply your GitHub-hosted allowance before comparing paid usage.

When is a managed runner the better choice?

GitHub Actions runners on Lizard run GitHub’s actions/runner from a JIT configuration in a fresh Firecracker VM per job; the runner registration is removed afterward. Lizard keeps a cache disk only for private repositories, and jobs from forks never run with it. Pull requests and other branches can read the cache, but only pushes, schedules and manual runs on the default branch save to it.

Lizard runnervCPURAM shown on the product pageWorkflow labelHourly usage rate
Small24 GBlizard-small-ubuntu-2404$0.0162
Medium48 GBlizard-medium-ubuntu-2404$0.0324
Large816 GBlizard-large-ubuntu-2404$0.0648

Lizard Pro costs $19 a month, taxes included, with $19 of usage included; that is the monthly minimum, with usage above it pay as you go. Runner VM time is billed by the second, including setup and idle time.

Lizard runners support Linux x64 on Ubuntu 24.04 only and GitHub organizations, not personal accounts. An organization admin installs the Lizard GitHub App. The 7-day trial requires a card, includes $5 of usage, and allows only Small runners, up to 4 vCPU at a time; workloads pause when trial usage runs out unless you start Pro.

Use the Small label for a trial job:

runs-on: lizard-small-ubuntu-2404

Windows, macOS and ARM jobs stay on GitHub; choose a suitable provider for GPU jobs. Benchmark providers if the shortest build time matters, use an own-cloud setup if required, and keep free GitHub-hosted runners for public projects when they meet your needs. See managed runners and other CI platforms compared.

Lizard was cheaper and slower on our frontend build; see the measured build and Lizard runner setup, then try one trusted Linux job and compare its runtime, total bill and upkeep.

Frequently asked questions

How do you set environment variables on a GitHub Actions self-hosted runner? Put BUILD_MODE=test in actions-runner/.env, for example, then restart the service with sudo ./svc.sh stop and sudo ./svc.sh start. Use literal NAME=value lines, without export, shell expansion or quotes around values. GitHub’s proxy instructions document .env use; the runner’s parser reads it at startup, while env.sh saves PATH separately in .path. Jobs can read these values, so use workflow env: for job settings and GitHub secrets for credentials. Keep .env out of version control.

Are GitHub self-hosted runners free? As of 4 November 2026, GitHub charges no self-hosted compute fee, according to its billing docs. You still pay for the machine and its upkeep.

Does a self-hosted runner need an open inbound port? No. GitHub’s communication requirements call for outbound HTTPS on port 443; the runner needs no inbound webhook port.

How long does a runner registration token last? GitHub’s runner setup instructions say one hour. Generate a fresh token if registration fails because it expired.

Build with AI. Ship with Lizard.

You don't need a platform team to go live. Your whole cloud, one CLI command away.

Services
—
Add-ons
—
Deployments
—
Sandbox starts
—

We use cookies for essential site functionality and analytics. See our Cookie Policy.