GitHub Actions self-hosted runners
Deploy, configure, and scale GitHub Actions self-hosted runners on your infrastructure
GitHub Actions self-hosted runners execute workflows on infrastructure you own and manage. They connect to GitHub over HTTPS, poll for jobs, execute them locally, and report results back. Unlike GitHub-hosted runners, you control the hardware, OS, installed software, and network environment.
Architecture overview#
1┌──────────────────────┐ HTTPS (long poll)2│ GitHub Actions │◄─────────────────────────────┐3│ (github.com) │──────── Job payload ─────────►│4└──────────────────────┘ │5 │6 ┌─────────────────────────┤7 │ Self-Hosted Runner │8 │ │9 │ ┌────────────────────┐ │10 │ │ Runner Application │ │11 │ │ (actions/runner) │ │12 │ └────────┬───────────┘ │13 │ │ │14 │ ┌────────▼───────────┐ │15 │ │ Job Execution │ │16 │ │ - Clone repo │ │17 │ │ - Run steps │ │18 │ │ - Upload artifacts │ │19 │ └────────────────────┘ │20 └──────────────────────────┘The runner application is a .NET agent that authenticates with GitHub using a registration token, then maintains a long-poll HTTPS connection to receive jobs. All communication is outbound — no inbound ports are required.
Prerequisites#
- Linux server (Ubuntu 22.04+ or RHEL 8+ recommended), macOS, or Windows
- 2+ CPU cores, 4 GB+ RAM, 20 GB+ free disk
- Outbound HTTPS access to
github.com,api.github.com,*.actions.githubusercontent.com - A GitHub repository, organization, or enterprise with admin access
- A dedicated non-root user for running the agent
Installation#
Download and extract#
Download the latest runner package from the actions/runner releases page. Choose the architecture matching your server.
1# Create a dedicated user2sudo useradd -m -s /bin/bash runner3sudo su - runner45# Create runner directory6mkdir -p ~/actions-runner && cd ~/actions-runner78# Download latest runner (Linux x64)9RUNNER_VERSION=$(curl -s https://api.github.com/repos/actions/runner/releases/latest | grep -oP '"tag_name": "v\K[^"]+')10curl -o actions-runner.tar.gz -L "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"1112# Verify checksum13echo "<sha256-from-release-page> actions-runner.tar.gz" | shasum -a 256 -c1415# Extract16tar xzf actions-runner.tar.gzFor ARM64 servers, replace x64 with arm64 in the download URL.
Register the runner#
Navigate to your repository or organization settings: Settings → Actions → Runners → New self-hosted runner. Copy the registration token shown, then run:
1# Repository-level runner2./config.sh \3 --url https://github.com/YOUR-ORG/YOUR-REPO \4 --token YOUR_REGISTRATION_TOKEN \5 --name "prod-runner-01" \6 --labels "linux,x64,production" \7 --work "_work"89# Organization-level runner10./config.sh \11 --url https://github.com/YOUR-ORG \12 --token YOUR_ORG_TOKEN \13 --name "org-runner-01" \14 --labels "linux,x64,shared" \15 --runnergroup "production"Key flags:
--name— unique identifier shown in the GitHub UI--labels— comma-separated labels used inruns-onto route jobs--runnergroup— assign to a runner group (org/enterprise only)--work— working directory for job execution (default:_work)--replace— replace an existing runner with the same name--ephemeral— runner picks up one job and then unregisters (see Ephemeral runners)
Run as a systemd service#
Running the agent as a systemd service ensures it starts on boot and restarts on failure.
1# Install the service (run from the runner directory)2sudo ./svc.sh install runner34# Start the service5sudo ./svc.sh start67# Check status8sudo ./svc.sh statusThe install script creates a systemd unit at /etc/systemd/system/actions.runner.<org>-<repo>.<name>.service. You can also create the unit manually for more control:
1# /etc/systemd/system/actions-runner.service2[Unit]3Description=GitHub Actions Runner4After=network.target56[Service]7Type=simple8User=runner9WorkingDirectory=/home/runner/actions-runner10ExecStart=/home/runner/actions-runner/run.sh11Restart=always12RestartSec=513KillSignal=SIGTERM14TimeoutStopSec=5min1516# Hardening17NoNewPrivileges=yes18ProtectSystem=strict19ReadWritePaths=/home/runner20PrivateTmp=yes2122[Install]23WantedBy=multi-user.target1sudo systemctl daemon-reload2sudo systemctl enable --now actions-runner.serviceContainer-based runner#
Run the runner in a container using podman or docker:
1# Using the official runner image2podman run -d \3 --name github-runner \4 --restart unless-stopped \5 -e RUNNER_NAME="container-runner-01" \6 -e RUNNER_TOKEN="YOUR_TOKEN" \7 -e RUNNER_REPOSITORY_URL="https://github.com/YOUR-ORG/YOUR-REPO" \8 -e RUNNER_LABELS="linux,container" \9 -v runner-work:/home/runner/_work \10 ghcr.io/actions/actions-runner:latestFor Docker-in-Docker workflows, mount the Docker socket or use rootless containers:
1# Mount host Docker socket (less isolated)2podman run -d \3 --name github-runner \4 -v /var/run/docker.sock:/var/run/docker.sock \5 -e RUNNER_NAME="dind-runner" \6 -e RUNNER_TOKEN="YOUR_TOKEN" \7 -e RUNNER_REPOSITORY_URL="https://github.com/YOUR-ORG/YOUR-REPO" \8 ghcr.io/actions/actions-runner:latestConfiguration#
Labels and job routing#
Labels control which runners receive which jobs. Every runner automatically gets three default labels: self-hosted, the OS name (Linux, Windows, macOS), and the architecture (X64, ARM64).
Add custom labels during registration or update them later:
1# Add labels at registration2./config.sh --url ... --token ... --labels "linux,x64,gpu,production"34# Update labels without re-registering (via GitHub API)5curl -X PUT \6 -H "Authorization: Bearer YOUR_PAT" \7 -H "Accept: application/vnd.github+json" \8 "https://api.github.com/repos/OWNER/REPO/actions/runners/RUNNER_ID/labels" \9 -d '{"labels":["linux","x64","gpu","production"]}'Use labels in workflows:
1jobs:2 build:3 runs-on: [self-hosted, linux, x64, production]4 steps:5 - uses: actions/checkout@v46 - run: make build78 gpu-test:9 runs-on: [self-hosted, gpu]10 steps:11 - run: python train_model.pyRunner groups#
Runner groups (organization and enterprise only) control which repositories can use which runners:
- Settings → Actions → Runner groups → New runner group
- Name the group (e.g.,
production,staging) - Select which repositories can access the group
- Assign runners to the group during registration with
--runnergroup
1# Only runs on runners in the "production" group2jobs:3 deploy:4 runs-on:5 group: production6 labels: [linux, x64]Organization vs repository runners#
| Scope | Registration URL | Visibility | Use case |
|---|---|---|---|
| Repository | github.com/OWNER/REPO | Single repo only | Dedicated workloads, sensitive repos |
| Organization | github.com/ORG | All or selected repos | Shared infrastructure, cost efficiency |
| Enterprise | github.com/enterprises/ENT | All orgs in enterprise | Centralized fleet management |
Organization runners are recommended for most setups — they reduce management overhead while runner groups provide access control.
Proxy and firewall configuration#
If runners are behind a corporate proxy:
1# Set proxy during configuration2export http_proxy=http://proxy.corp.internal:80803export https_proxy=http://proxy.corp.internal:80804export no_proxy=localhost,127.0.0.1,10.0.0.0/856./config.sh --url ... --token ...The runner respects standard proxy environment variables. Add them to the systemd service for persistence:
1# In the [Service] section of the systemd unit2Environment="http_proxy=http://proxy.corp.internal:8080"3Environment="https_proxy=http://proxy.corp.internal:8080"4Environment="no_proxy=localhost,127.0.0.1,10.0.0.0/8"Required outbound endpoints (allow these through your firewall):
| Endpoint | Port | Purpose |
|---|---|---|
github.com | 443 | API and web |
api.github.com | 443 | REST API |
*.actions.githubusercontent.com | 443 | Action downloads, logs, artifacts |
ghcr.io | 443 | Container images |
*.blob.core.windows.net | 443 | Artifact storage |
Custom CA certificates#
For environments using TLS inspection or private CAs:
1# Add custom CA cert before configuring the runner2sudo cp corp-ca.crt /usr/local/share/ca-certificates/3sudo update-ca-certificates45# Set NODE_EXTRA_CA_CERTS for the runner process6export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-ca.crtAdd to the systemd unit for persistence:
1Environment="NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-ca.crt"Environment variables#
Pass environment variables to all jobs on a runner via a .env file:
1# ~/actions-runner/.env2DOCKER_HOST=unix:///var/run/docker.sock3MAVEN_OPTS=-Xmx4g4NODE_OPTIONS=--max-old-space-size=4096Variables in .env are available to every job that runs on this runner.
Ephemeral runners#
Ephemeral runners pick up exactly one job, then automatically unregister. This provides a clean environment for every job and eliminates state leakage between workflows.
1./config.sh \2 --url https://github.com/YOUR-ORG \3 --token YOUR_TOKEN \4 --name "ephemeral-$(hostname)-$$" \5 --labels "linux,ephemeral" \6 --ephemeralAfter the job completes, the runner process exits. Combine with a wrapper script or container orchestration to automatically provision fresh runners:
1#!/bin/bash2# run-ephemeral.sh — loops to re-register and run one job at a time3while true; do4 TOKEN=$(curl -s -X POST \5 -H "Authorization: Bearer $PAT" \6 "https://api.github.com/orgs/YOUR-ORG/actions/runners/registration-token" \7 | jq -r .token)89 ./config.sh --url https://github.com/YOUR-ORG \10 --token "$TOKEN" \11 --name "ephemeral-$(hostname)-$$" \12 --labels "linux,ephemeral" \13 --ephemeral \14 --replace1516 ./run.sh1718 # Clean up work directory between jobs19 rm -rf _work/*20doneAutoscaling#
Actions Runner Controller (ARC)#
For Kubernetes environments, Actions Runner Controller provides webhook-driven autoscaling of ephemeral runners.
Install ARC using Helm:
1# Install the controller2helm install arc \3 --namespace arc-systems \4 --create-namespace \5 oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller67# Deploy a runner scale set8helm install arc-runner-set \9 --namespace arc-runners \10 --create-namespace \11 -f values.yaml \12 oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-setExample values.yaml:
1githubConfigUrl: "https://github.com/YOUR-ORG"2githubConfigSecret:3 github_token: "YOUR_PAT"45runnerScaleSetName: "arc-runner-set"67maxRunners: 208minRunners: 0910template:11 spec:12 containers:13 - name: runner14 image: ghcr.io/actions/actions-runner:latest15 resources:16 requests:17 cpu: "2"18 memory: "4Gi"19 limits:20 cpu: "4"21 memory: "8Gi"Use in workflows:
1jobs:2 build:3 runs-on: arc-runner-setARC creates a runner pod per job, which is destroyed after completion — ephemeral by default.
Webhook-driven scaling without Kubernetes#
For VM-based environments, use the workflow_job webhook to trigger runner provisioning:
1# Listen for workflow_job events via a webhook receiver2# When event.action == "queued", provision a new runner3# When event.action == "completed", deprovision45# Example: create a VM, register an ephemeral runner, run it6create_runner() {7 local VM_ID=$(create_vm) # Your cloud provider CLI8 ssh runner@$VM_ID "cd ~/actions-runner && ./config.sh --ephemeral --url ... --token ... && ./run.sh"9}This pattern works with any cloud provider or VM manager (Linode, Hetzner, AWS EC2, etc.).
Token management#
Registration tokens#
Registration tokens expire after 1 hour. Generate them programmatically:
1# Repository-level2curl -X POST \3 -H "Authorization: Bearer YOUR_PAT" \4 "https://api.github.com/repos/OWNER/REPO/actions/runners/registration-token" \5 | jq -r .token67# Organization-level8curl -X POST \9 -H "Authorization: Bearer YOUR_PAT" \10 "https://api.github.com/orgs/YOUR-ORG/actions/runners/registration-token" \11 | jq -r .tokenFine-grained PATs#
Use fine-grained personal access tokens with minimal scope:
- Repository runners:
Administration: Read and writeon the target repo - Organization runners:
Organization self hosted runners: Write
Avoid classic PATs with broad admin:org scope.
OIDC for cloud authentication#
Use GitHub's OIDC provider to get short-lived cloud credentials without storing secrets:
1jobs:2 deploy:3 runs-on: [self-hosted, production]4 permissions:5 id-token: write6 contents: read7 steps:8 - uses: aws-actions/configure-aws-credentials@v49 with:10 role-to-arn: arn:aws:iam::123456789:role/github-deploy11 aws-region: eu-central-112 - run: aws s3 sync ./dist s3://my-bucketTroubleshooting#
Runner offline in GitHub UI#
1# Check the service is running2systemctl status actions.runner.* 2>/dev/null || systemctl status actions-runner34# Check runner logs5journalctl -u actions-runner -n 50 --no-pager67# Verify network connectivity8curl -sI https://api.github.com | head -19curl -sI https://pipelines.actions.githubusercontent.com | head -11011# Check DNS resolution12dig +short api.github.comCommon causes:
- Service not running or crashed — restart with
sudo systemctl restart actions-runner - Firewall blocking outbound HTTPS — verify access to required endpoints
- Registration token expired — re-register with a fresh token
- Runner replaced by another with the same name — use
--replaceflag
Jobs queue but never start#
1# Verify runner labels match the workflow's runs-on2grep -A5 "runs-on" .github/workflows/*.yml34# Check runner is idle and accepting jobs5curl -s -H "Authorization: Bearer $PAT" \6 "https://api.github.com/repos/OWNER/REPO/actions/runners" \7 | jq '.runners[] | {name, status, busy, labels: [.labels[].name]}'Common causes:
- Label mismatch between
runs-onand runner labels - Runner is busy with another job
- Runner group doesn't include the repository
- Runner is offline (see above)
Disk space exhaustion#
The _work directory grows with every job. Clean it periodically:
1# Check disk usage2du -sh ~/actions-runner/_work/34# Remove old work directories (keep last 24 hours)5find ~/actions-runner/_work -maxdepth 1 -type d -mtime +1 -exec rm -rf {} +67# Automated cleanup via cron8echo "0 3 * * * runner find /home/runner/actions-runner/_work -maxdepth 1 -type d -mtime +1 -exec rm -rf {} +" \9 | sudo tee /etc/cron.d/runner-cleanupFor Docker-based workflows, also prune images and containers:
1# Clean Docker artifacts2docker system prune -af --filter "until=24h"Permission errors#
1# Fix ownership of runner directory2sudo chown -R runner:runner ~/actions-runner34# Ensure runner user can access Docker (if needed)5sudo usermod -aG docker runner67# Check SELinux (RHEL/CentOS)8sudo ausearch -m AVC -ts recentWalkthrough: DigitalOcean Droplet setup#
For a step-by-step visual guide to setting up a runner on DigitalOcean, see the walkthrough below.
Create a Droplet#
From DigitalOcean, select Droplets → Create Droplet. Choose Ubuntu and your preferred datacenter.

Select the droplet size and CPU options:

Choose an authentication method and give the droplet a name:

Configure the server#
1# SSH into the droplet2ssh root@<DROPLET_IP>34# Create a dedicated runner user5adduser runner6usermod -aG sudo runner78# Switch to the runner user and follow the installation steps above9su - runner10mkdir -p ~/actions-runner && cd ~/actions-runner11# Download, extract, configure, and install as a service (see Installation section)Verify the runner#
After installation, your runner appears in the GitHub UI with an Idle status:

Test with a workflow#
1name: Test Self-Hosted Runner2on: workflow_dispatch34jobs:5 test:6 runs-on: self-hosted7 steps:8 - uses: actions/checkout@v49 - name: System info10 run: |11 echo "Runner: $(hostname)"12 echo "OS: $(cat /etc/os-release | grep PRETTY_NAME)"13 echo "CPU: $(nproc) cores"14 echo "Memory: $(free -h | awk '/Mem:/{print $2}')"15 echo "Disk: $(df -h / | awk 'NR==2{print $4}') free"16 - name: Run tests17 run: echo "Tests passed on self-hosted runner"Next steps#
- Platform comparison — Compare GitHub Actions with GitLab, Jenkins, and Bazel
- Security hardening — Lock down your runner infrastructure
- Troubleshooting — Comprehensive error resolution guide
- Bare metal deployment — Production infrastructure setup