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.