Runners

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 user
2
sudo useradd -m -s /bin/bash runner
3
sudo su - runner
4
5
# Create runner directory
6
mkdir -p ~/actions-runner && cd ~/actions-runner
7
8
# Download latest runner (Linux x64)
9
RUNNER_VERSION=$(curl -s https://api.github.com/repos/actions/runner/releases/latest | grep -oP '"tag_name": "v\K[^"]+')
10
curl -o actions-runner.tar.gz -L "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
11
12
# Verify checksum
13
echo "<sha256-from-release-page> actions-runner.tar.gz" | shasum -a 256 -c
14
15
# Extract
16
tar xzf actions-runner.tar.gz

For 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 runner
2
./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"
8
9
# Organization-level runner
10
./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 in runs-on to 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)
2
sudo ./svc.sh install runner
3
4
# Start the service
5
sudo ./svc.sh start
6
7
# Check status
8
sudo ./svc.sh status

The 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.service
2
[Unit]
3
Description=GitHub Actions Runner
4
After=network.target
5
6
[Service]
7
Type=simple
8
User=runner
9
WorkingDirectory=/home/runner/actions-runner
10
ExecStart=/home/runner/actions-runner/run.sh
11
Restart=always
12
RestartSec=5
13
KillSignal=SIGTERM
14
TimeoutStopSec=5min
15
16
# Hardening
17
NoNewPrivileges=yes
18
ProtectSystem=strict
19
ReadWritePaths=/home/runner
20
PrivateTmp=yes
21
22
[Install]
23
WantedBy=multi-user.target
1
sudo systemctl daemon-reload
2
sudo systemctl enable --now actions-runner.service

Container-based runner#

Run the runner in a container using podman or docker:

1
# Using the official runner image
2
podman 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:latest

For Docker-in-Docker workflows, mount the Docker socket or use rootless containers:

1
# Mount host Docker socket (less isolated)
2
podman 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:latest

Configuration#

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 registration
2
./config.sh --url ... --token ... --labels "linux,x64,gpu,production"
3
4
# Update labels without re-registering (via GitHub API)
5
curl -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:

1
jobs:
2
build:
3
runs-on: [self-hosted, linux, x64, production]
4
steps:
5
- uses: actions/checkout@v4
6
- run: make build
7
8
gpu-test:
9
runs-on: [self-hosted, gpu]
10
steps:
11
- run: python train_model.py

Runner groups#

Runner groups (organization and enterprise only) control which repositories can use which runners:

  1. Settings → Actions → Runner groups → New runner group
  2. Name the group (e.g., production, staging)
  3. Select which repositories can access the group
  4. Assign runners to the group during registration with --runnergroup
1
# Only runs on runners in the "production" group
2
jobs:
3
deploy:
4
runs-on:
5
group: production
6
labels: [linux, x64]

Organization vs repository runners#

ScopeRegistration URLVisibilityUse case
Repositorygithub.com/OWNER/REPOSingle repo onlyDedicated workloads, sensitive repos
Organizationgithub.com/ORGAll or selected reposShared infrastructure, cost efficiency
Enterprisegithub.com/enterprises/ENTAll orgs in enterpriseCentralized 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 configuration
2
export http_proxy=http://proxy.corp.internal:8080
3
export https_proxy=http://proxy.corp.internal:8080
4
export no_proxy=localhost,127.0.0.1,10.0.0.0/8
5
6
./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 unit
2
Environment="http_proxy=http://proxy.corp.internal:8080"
3
Environment="https_proxy=http://proxy.corp.internal:8080"
4
Environment="no_proxy=localhost,127.0.0.1,10.0.0.0/8"

Required outbound endpoints (allow these through your firewall):

EndpointPortPurpose
github.com443API and web
api.github.com443REST API
*.actions.githubusercontent.com443Action downloads, logs, artifacts
ghcr.io443Container images
*.blob.core.windows.net443Artifact storage

Custom CA certificates#

For environments using TLS inspection or private CAs:

1
# Add custom CA cert before configuring the runner
2
sudo cp corp-ca.crt /usr/local/share/ca-certificates/
3
sudo update-ca-certificates
4
5
# Set NODE_EXTRA_CA_CERTS for the runner process
6
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-ca.crt

Add to the systemd unit for persistence:

1
Environment="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/.env
2
DOCKER_HOST=unix:///var/run/docker.sock
3
MAVEN_OPTS=-Xmx4g
4
NODE_OPTIONS=--max-old-space-size=4096

Variables 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
--ephemeral

After the job completes, the runner process exits. Combine with a wrapper script or container orchestration to automatically provision fresh runners:

1
#!/bin/bash
2
# run-ephemeral.sh — loops to re-register and run one job at a time
3
while true; do
4
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)
8
9
./config.sh --url https://github.com/YOUR-ORG \
10
--token "$TOKEN" \
11
--name "ephemeral-$(hostname)-$$" \
12
--labels "linux,ephemeral" \
13
--ephemeral \
14
--replace
15
16
./run.sh
17
18
# Clean up work directory between jobs
19
rm -rf _work/*
20
done

Autoscaling#

Actions Runner Controller (ARC)#

For Kubernetes environments, Actions Runner Controller provides webhook-driven autoscaling of ephemeral runners.

Install ARC using Helm:

1
# Install the controller
2
helm install arc \
3
--namespace arc-systems \
4
--create-namespace \
5
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
6
7
# Deploy a runner scale set
8
helm 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-set

Example values.yaml:

1
githubConfigUrl: "https://github.com/YOUR-ORG"
2
githubConfigSecret:
3
github_token: "YOUR_PAT"
4
5
runnerScaleSetName: "arc-runner-set"
6
7
maxRunners: 20
8
minRunners: 0
9
10
template:
11
spec:
12
containers:
13
- name: runner
14
image: ghcr.io/actions/actions-runner:latest
15
resources:
16
requests:
17
cpu: "2"
18
memory: "4Gi"
19
limits:
20
cpu: "4"
21
memory: "8Gi"

Use in workflows:

1
jobs:
2
build:
3
runs-on: arc-runner-set

ARC 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 receiver
2
# When event.action == "queued", provision a new runner
3
# When event.action == "completed", deprovision
4
5
# Example: create a VM, register an ephemeral runner, run it
6
create_runner() {
7
local VM_ID=$(create_vm) # Your cloud provider CLI
8
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-level
2
curl -X POST \
3
-H "Authorization: Bearer YOUR_PAT" \
4
"https://api.github.com/repos/OWNER/REPO/actions/runners/registration-token" \
5
| jq -r .token
6
7
# Organization-level
8
curl -X POST \
9
-H "Authorization: Bearer YOUR_PAT" \
10
"https://api.github.com/orgs/YOUR-ORG/actions/runners/registration-token" \
11
| jq -r .token

Fine-grained PATs#

Use fine-grained personal access tokens with minimal scope:

  • Repository runners: Administration: Read and write on 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:

1
jobs:
2
deploy:
3
runs-on: [self-hosted, production]
4
permissions:
5
id-token: write
6
contents: read
7
steps:
8
- uses: aws-actions/configure-aws-credentials@v4
9
with:
10
role-to-arn: arn:aws:iam::123456789:role/github-deploy
11
aws-region: eu-central-1
12
- run: aws s3 sync ./dist s3://my-bucket

Troubleshooting#

Runner offline in GitHub UI#

1
# Check the service is running
2
systemctl status actions.runner.* 2>/dev/null || systemctl status actions-runner
3
4
# Check runner logs
5
journalctl -u actions-runner -n 50 --no-pager
6
7
# Verify network connectivity
8
curl -sI https://api.github.com | head -1
9
curl -sI https://pipelines.actions.githubusercontent.com | head -1
10
11
# Check DNS resolution
12
dig +short api.github.com

Common 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 --replace flag

Jobs queue but never start#

1
# Verify runner labels match the workflow's runs-on
2
grep -A5 "runs-on" .github/workflows/*.yml
3
4
# Check runner is idle and accepting jobs
5
curl -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-on and 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 usage
2
du -sh ~/actions-runner/_work/
3
4
# Remove old work directories (keep last 24 hours)
5
find ~/actions-runner/_work -maxdepth 1 -type d -mtime +1 -exec rm -rf {} +
6
7
# Automated cleanup via cron
8
echo "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-cleanup

For Docker-based workflows, also prune images and containers:

1
# Clean Docker artifacts
2
docker system prune -af --filter "until=24h"

Permission errors#

1
# Fix ownership of runner directory
2
sudo chown -R runner:runner ~/actions-runner
3
4
# Ensure runner user can access Docker (if needed)
5
sudo usermod -aG docker runner
6
7
# Check SELinux (RHEL/CentOS)
8
sudo ausearch -m AVC -ts recent

Walkthrough: 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.

Screenshot of Digital Ocean Create Droploet

Select the droplet size and CPU options:

Screenshot of Digital Ocean Droplet Config - CPU Size

Choose an authentication method and give the droplet a name:

Screenshot of Digital Ocean Droplet Config- Droplet Name

Configure the server#

1
# SSH into the droplet
2
ssh root@<DROPLET_IP>
3
4
# Create a dedicated runner user
5
adduser runner
6
usermod -aG sudo runner
7
8
# Switch to the runner user and follow the installation steps above
9
su - runner
10
mkdir -p ~/actions-runner && cd ~/actions-runner
11
# 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:

Screenshot of GitHub - Runner Idle

Test with a workflow#

1
name: Test Self-Hosted Runner
2
on: workflow_dispatch
3
4
jobs:
5
test:
6
runs-on: self-hosted
7
steps:
8
- uses: actions/checkout@v4
9
- name: System info
10
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 tests
17
run: echo "Tests passed on self-hosted runner"

Next steps#