When GitHub-hosted runners aren’t enough—when you need GPU access, specific hardware, private network connectivity, or just want to stop paying per-minute—self-hosted runners are the answer.

Why Self-Hosted?

Performance: Your hardware, your speed. No cold starts, local caching, faster artifact access.

Cost: After a certain threshold, self-hosted is dramatically cheaper. GitHub-hosted minutes add up fast for active repos.

Access: Private networks, internal services, specialized hardware, air-gapped environments.

Control: Exact OS versions, pre-installed dependencies, custom security configurations.

Basic Setup

Install the Runner

Download and configure on your server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Create a directory
mkdir -p ~/actions-runner && cd ~/actions-runner

# Download latest runner (check GitHub for current version)
curl -o actions-runner-linux-x64.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz

# Extract
tar xzf ./actions-runner-linux-x64.tar.gz

# Configure (get token from GitHub repo settings)
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \
  --token YOUR_REGISTRATION_TOKEN

Run as a Service

Don’t just ./run.sh and walk away. Set up proper service management:

1
2
3
4
5
6
7
8
# Install the service
sudo ./svc.sh install

# Start it
sudo ./svc.sh start

# Check status
sudo ./svc.sh status

Or use systemd directly for more control:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# /etc/systemd/system/github-runner.service
[Unit]
Description=GitHub Actions Runner
After=network.target

[Service]
Type=simple
User=runner
WorkingDirectory=/home/runner/actions-runner
ExecStart=/home/runner/actions-runner/run.sh
Restart=always
RestartSec=10
KillSignal=SIGTERM
TimeoutStopSec=5min

[Install]
WantedBy=multi-user.target

Runner Labels

Labels determine which workflows can use your runner:

1
2
3
4
# During config, add custom labels
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \
  --token TOKEN \
  --labels gpu,linux,x64,docker

In your workflow:

1
2
3
4
5
6
jobs:
  build:
    runs-on: [self-hosted, linux, gpu]
    steps:
      - uses: actions/checkout@v4
      # GPU-accelerated builds here

Security Considerations

Self-hosted runners execute arbitrary code from your workflows. Secure them properly.

Dedicated User

Never run as root. Create a dedicated user:

1
2
sudo useradd -m -s /bin/bash runner
sudo usermod -aG docker runner  # if needed

Ephemeral Runners

For public repos or untrusted contributors, use ephemeral runners that reset after each job:

1
2
3
./config.sh --ephemeral \
  --url https://github.com/YOUR_ORG/YOUR_REPO \
  --token TOKEN

The runner de-registers after completing one job. Combine with automation to spin up fresh instances.

Network Isolation

Put runners in a dedicated network segment:

1
2
3
4
5
# Example: firewall rules allowing only required traffic
sudo ufw default deny incoming
sudo ufw allow from 10.0.0.0/8 to any port 22  # SSH from internal
sudo ufw allow out 443  # GitHub API
sudo ufw enable

Scaling with Docker

For dynamic scaling, run runners in containers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FROM ubuntu:22.04

ARG RUNNER_VERSION=2.311.0
ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y \
    curl jq git sudo \
    && rm -rf /var/lib/apt/lists/*

RUN useradd -m runner

WORKDIR /home/runner

RUN 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 \
    && tar xzf actions-runner.tar.gz \
    && rm actions-runner.tar.gz \
    && ./bin/installdependencies.sh

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

USER runner
ENTRYPOINT ["/entrypoint.sh"]

With an entrypoint that handles registration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
# entrypoint.sh

# Get registration token from API
REG_TOKEN=$(curl -s -X POST \
  -H "Authorization: token ${GITHUB_PAT}" \
  -H "Accept: application/vnd.github.v3+json" \
  "https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/actions/runners/registration-token" \
  | jq -r .token)

# Configure
./config.sh --url "https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}" \
  --token "${REG_TOKEN}" \
  --ephemeral \
  --unattended \
  --labels "${RUNNER_LABELS:-self-hosted,linux,x64}"

# Run
./run.sh

Organization-Level Runners

For multiple repos, register at the org level:

1
2
3
./config.sh --url https://github.com/YOUR_ORG \
  --token ORG_LEVEL_TOKEN \
  --runnergroup "Production Runners"

Create runner groups in GitHub to control which repos can access which runners.

Monitoring and Maintenance

Health Checks

Monitor runner status:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/bin/bash
# check-runner.sh

RUNNER_STATUS=$(curl -s \
  -H "Authorization: token ${GITHUB_PAT}" \
  -H "Accept: application/vnd.github.v3+json" \
  "https://api.github.com/repos/${OWNER}/${REPO}/actions/runners" \
  | jq -r '.runners[] | select(.name=="'$(hostname)'") | .status')

if [ "$RUNNER_STATUS" != "online" ]; then
  echo "Runner offline, restarting..."
  sudo systemctl restart github-runner
fi

Automatic Updates

Runners auto-update, but monitor for issues:

1
2
3
4
5
# Check runner version
cat ~/actions-runner/.runner | jq -r .agentVersion

# Force update if needed
./run.sh --check

Cleanup

Runners accumulate work directories. Clean periodically:

1
2
# Cron job to clean old work directories
0 3 * * * find /home/runner/actions-runner/_work -maxdepth 2 -mtime +7 -type d -exec rm -rf {} \;

Real-World Configuration

Here’s a production-ready workflow using self-hosted runners:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: [self-hosted, linux, docker]
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Build Docker Image
        run: |
          docker build -t myapp:${{ github.sha }} .
          
      - name: Run Tests
        run: |
          docker run --rm myapp:${{ github.sha }} npm test
          
      - name: Push to Registry
        run: |
          docker tag myapp:${{ github.sha }} registry.internal/myapp:${{ github.sha }}
          docker push registry.internal/myapp:${{ github.sha }}

  deploy:
    needs: build
    runs-on: [self-hosted, linux, production]
    environment: production
    
    steps:
      - name: Deploy
        run: |
          kubectl set image deployment/myapp \
            myapp=registry.internal/myapp:${{ github.sha }}

Troubleshooting

Runner not picking up jobs: Check labels match exactly. Labels are case-sensitive.

Permission denied errors: Ensure the runner user has appropriate permissions. Check Docker socket access if using containers.

Runner going offline: Check network connectivity to GitHub. Ensure actions.githubusercontent.com and github.com are accessible.

Slow artifact upload/download: Self-hosted runners still use GitHub for artifacts by default. Consider using your own artifact storage for large files.

The Payoff

Self-hosted runners require upfront investment but pay dividends:

  • Build times often cut by 50% or more
  • No minute-based billing
  • Access to internal resources
  • Complete environment control

Start with one runner for your most demanding workflow. Once you see the improvement, you’ll want more.


Self-hosted runners bridge the gap between CI/CD convenience and infrastructure control. Set them up right, secure them properly, and they’ll serve you reliably for years.