The first time I looked at GitHub’s bill for Actions minutes, I did a double-take. We were burning through thousands of minutes a month on a moderately active project. Nothing crazy—just regular CI/CD, some iOS builds, the usual stuff. But at GitHub’s rates, “the usual stuff” adds up fast.
That’s when I fell down the self-hosted runner rabbit hole.
This is part of a series on GitHub Actions. If you haven’t read the intro, it covers the landscape of what’s possible with Actions. This post goes deep on what happens when GitHub’s provided runners aren’t enough for your needs.
Why Self-Host in the First Place?
GitHub-hosted runners are genuinely convenient. Zero setup, consistent environment, automatic updates. For a lot of teams, they’re the right choice.
But there are good reasons to run your own infrastructure:
Cost at scale. GitHub Actions minutes aren’t cheap—especially for macOS (10x Linux cost) and Windows (2x Linux cost). If you’re running hundreds of builds daily, self-hosting can cut your bill by 80% or more.
Performance. GitHub’s standard runners are decent but not impressive—2-core machines with 7GB RAM. If your builds are resource-hungry, you’re waiting longer than you need to.
Specialized hardware. Need GPUs for ML workloads? ARM processors for native builds? Specific hardware for integration tests? GitHub doesn’t offer those.
Security requirements. Some organizations can’t run code on third-party infrastructure. Compliance, air-gapped networks, data residency requirements—sometimes the code simply cannot leave your premises.
Network access. Need to hit internal services during your build? Deploy to private infrastructure? Self-hosted runners can live inside your network.
Let me show you how this actually works.
The Basics: Adding a Runner
The simplest self-hosted setup takes about five minutes. Go to your repository (or organization) settings, find Actions → Runners, and click “New self-hosted runner.”
GitHub gives you a script to run. It looks something like this:
# Create a directory
mkdir actions-runner && cd actions-runner
# Download the runner
curl -o actions-runner-linux-x64-2.314.1.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.314.1/actions-runner-linux-x64-2.314.1.tar.gz
# Extract
tar xzf ./actions-runner-linux-x64-2.314.1.tar.gz
# Configure
./config.sh --url https://github.com/your-org/your-repo \
--token YOUR_TOKEN
# Run
./run.sh
That’s it. You now have a runner. Jobs will start appearing on it.
But this is the “getting started” version. Let’s talk about how to do this properly.
Labels: Routing Jobs to the Right Runners
Runners can have labels. Jobs can request specific labels. This is how you route work.
./config.sh --url https://github.com/your-org/your-repo \
--token YOUR_TOKEN \
--labels gpu,cuda,linux-large
Then in your workflow:
jobs:
train-model:
runs-on: [self-hosted, gpu, cuda]
steps:
- uses: actions/checkout@v4
- run: python train.py
Every self-hosted runner automatically gets these labels: self-hosted, the OS (linux, windows, macos), and the architecture (x64, arm64).
You can combine them:
# ARM64 macOS runner
runs-on: [self-hosted, macos, arm64]
# Any Linux runner with the 'large' label
runs-on: [self-hosted, linux, large]
This becomes crucial when you have a fleet of runners with different capabilities.
Setting Up Runners Properly on Each Platform
Linux
Linux is the easiest. Most CI workloads run on Linux, and the tooling is mature.
Here’s a proper setup script:
#!/bin/bash
set -e
RUNNER_VERSION="2.314.1"
RUNNER_USER="github-runner"
RUNNER_DIR="/opt/actions-runner"
GITHUB_URL="https://github.com/your-org"
REGISTRATION_TOKEN="YOUR_TOKEN"
# Create user
sudo useradd -m -s /bin/bash $RUNNER_USER
# Create directory
sudo mkdir -p $RUNNER_DIR
sudo chown $RUNNER_USER:$RUNNER_USER $RUNNER_DIR
# Download and extract
cd $RUNNER_DIR
sudo -u $RUNNER_USER 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"
sudo -u $RUNNER_USER tar xzf actions-runner.tar.gz
rm actions-runner.tar.gz
# Install dependencies
sudo ./bin/installdependencies.sh
# Configure
sudo -u $RUNNER_USER ./config.sh \
--url "$GITHUB_URL" \
--token "$REGISTRATION_TOKEN" \
--name "$(hostname)" \
--labels "linux,x64,large" \
--unattended \
--replace
# Install as service
sudo ./svc.sh install $RUNNER_USER
sudo ./svc.sh start
echo "Runner installed and started"
The key bits: dedicated user (don’t run as root), systemd service (survives reboots), unattended configuration (no interactive prompts).
macOS
macOS runners are where things get expensive with GitHub-hosted options—and where self-hosting makes the most sense if you’re doing iOS or macOS development.
#!/bin/bash
set -e
RUNNER_VERSION="2.314.1"
RUNNER_DIR="$HOME/actions-runner"
GITHUB_URL="https://github.com/your-org"
REGISTRATION_TOKEN="YOUR_TOKEN"
# Create directory
mkdir -p $RUNNER_DIR
cd $RUNNER_DIR
# Download (ARM64 for Apple Silicon)
curl -o actions-runner.tar.gz -L \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-osx-arm64-${RUNNER_VERSION}.tar.gz"
tar xzf actions-runner.tar.gz
rm actions-runner.tar.gz
# Configure
./config.sh \
--url "$GITHUB_URL" \
--token "$REGISTRATION_TOKEN" \
--name "$(hostname)" \
--labels "macos,arm64,ios" \
--unattended \
--replace
# Install as LaunchAgent (runs when user logs in)
./svc.sh install
# Start
./svc.sh start
macOS quirks to watch out for:
- The runner needs a logged-in user session for GUI operations (Simulator, notarization)
- Enable auto-login for the runner user
- Disable sleep and screen lock
- LaunchAgent vs LaunchDaemon matters—LaunchAgent runs in user session (what you usually want)
Windows
Windows is… Windows. It works, but you’ll need to jump through some hoops.
# Run as Administrator
$RUNNER_VERSION = "2.314.1"
$RUNNER_DIR = "C:\actions-runner"
$GITHUB_URL = "https://github.com/your-org"
$REGISTRATION_TOKEN = "YOUR_TOKEN"
# Create directory
New-Item -ItemType Directory -Path $RUNNER_DIR -Force
Set-Location $RUNNER_DIR
# Download
Invoke-WebRequest -Uri "https://github.com/actions/runner/releases/download/v$RUNNER_VERSION/actions-runner-win-x64-$RUNNER_VERSION.zip" -OutFile "actions-runner.zip"
Expand-Archive -Path "actions-runner.zip" -DestinationPath $RUNNER_DIR
Remove-Item "actions-runner.zip"
# Configure
.\config.cmd --url $GITHUB_URL `
--token $REGISTRATION_TOKEN `
--name $env:COMPUTERNAME `
--labels "windows,x64" `
--unattended `
--replace `
--runasservice
The --runasservice flag installs it as a Windows service. You’ll probably need to fiddle with service account permissions if your workflows need to do anything interesting.
Ephemeral vs Persistent Runners
There are two philosophies here:
Persistent runners stick around. They pick up jobs, run them, wait for more. Simple to set up, but they accumulate state over time—leftover files, environment changes, who knows what.
Ephemeral runners are created fresh for each job and destroyed afterward. Clean slate every time, but more complex to manage.
For ephemeral runners, add --ephemeral to the config:
./config.sh \
--url "$GITHUB_URL" \
--token "$REGISTRATION_TOKEN" \
--ephemeral \
--unattended
The runner will process exactly one job and then exit. You need something else (a VM orchestrator, Kubernetes, a shell loop) to create new runners.
Here’s a simple wrapper that keeps spawning ephemeral runners:
#!/bin/bash
while true; do
# Get fresh registration token
TOKEN=$(curl -s -X POST \
-H "Authorization: Bearer $GITHUB_PAT" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/orgs/YOUR_ORG/actions/runners/registration-token" \
| jq -r '.token')
# Configure and run (will exit after one job)
./config.sh \
--url "https://github.com/YOUR_ORG" \
--token "$TOKEN" \
--ephemeral \
--unattended \
--disableupdate
./run.sh
# Clean up
./config.sh remove --token "$TOKEN"
echo "Runner finished, starting fresh..."
done
This is hacky but works. For production, you want something more robust.
Auto-Scaling with Kubernetes
If you’re already running Kubernetes, the Actions Runner Controller (ARC) is the gold standard for self-hosted runners. It automatically scales runners based on demand.
First, install cert-manager (ARC dependency):
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.0/cert-manager.yaml
Then install ARC:
helm repo add actions-runner-controller \
https://actions-runner-controller.github.io/actions-runner-controller
helm repo update
helm install arc actions-runner-controller/actions-runner-controller \
--namespace actions-runner-system \
--create-namespace \
--set authSecret.create=true \
--set authSecret.github_token="YOUR_GITHUB_PAT"
Now create a runner deployment:
# runner-deployment.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: main-runners
namespace: actions-runner-system
spec:
replicas: 2
template:
spec:
repository: your-org/your-repo
labels:
- linux
- k8s
resources:
limits:
cpu: "2"
memory: "4Gi"
requests:
cpu: "1"
memory: "2Gi"
Apply it:
kubectl apply -f runner-deployment.yaml
But the real magic is autoscaling:
# runner-autoscaler.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
name: main-runners-autoscaler
namespace: actions-runner-system
spec:
scaleTargetRef:
kind: RunnerDeployment
name: main-runners
scaleUpTriggers:
- githubEvent:
workflowJob: {}
duration: "30m"
minReplicas: 1
maxReplicas: 20
metrics:
- type: TotalNumberOfQueuedAndInProgressWorkflowRuns
repositoryNames:
- your-org/your-repo
This scales runners from 1 to 20 based on the number of queued jobs. When a workflow starts, runners scale up. When the queue is empty, they scale back down.
Here’s a more complete production setup with different runner pools:
# Large runners for resource-intensive jobs
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: large-runners
spec:
replicas: 1
template:
spec:
organization: your-org
labels:
- linux
- large
resources:
limits:
cpu: "8"
memory: "32Gi"
requests:
cpu: "4"
memory: "16Gi"
nodeSelector:
node-type: high-memory
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
name: large-runners-autoscaler
spec:
scaleTargetRef:
kind: RunnerDeployment
name: large-runners
minReplicas: 0
maxReplicas: 10
scaleDownDelaySecondsAfterScaleOut: 300
metrics:
- type: PercentageRunnersBusy
scaleUpThreshold: "0.75"
scaleDownThreshold: "0.25"
scaleUpFactor: "2"
scaleDownFactor: "0.5"
Docker-in-Docker for Container Workflows
Many CI workflows need to build Docker images. Running Docker inside a container (Docker-in-Docker, or DinD) is the classic solution.
For Kubernetes-based runners:
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: docker-runners
spec:
template:
spec:
organization: your-org
labels:
- linux
- docker
dockerdWithinRunnerContainer: true
image: summerwind/actions-runner-dind
resources:
limits:
cpu: "4"
memory: "8Gi"
For standalone runners, you can mount the Docker socket:
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-e RUNNER_NAME="docker-runner-01" \
-e GITHUB_URL="https://github.com/your-org" \
-e RUNNER_TOKEN="YOUR_TOKEN" \
myorg/actions-runner:latest
Fair warning: mounting the Docker socket is a security risk. The runner can do anything the Docker daemon can do, which on most systems means root-equivalent access. Only do this on runners you control and trust.
A safer approach is rootless Docker or Podman, but that’s a whole other article.
GPU Runners for ML Workloads
If you’re training models or running inference tests, you need GPU access. GitHub-hosted runners don’t offer this (at least not at reasonable prices), so self-hosting is the only option for most teams.
Here’s a setup for NVIDIA GPUs with Kubernetes:
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: gpu-runners
spec:
template:
spec:
organization: your-org
labels:
- linux
- gpu
- cuda
resources:
limits:
nvidia.com/gpu: 1
cpu: "8"
memory: "32Gi"
nodeSelector:
nvidia.com/gpu.present: "true"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
Your workflow can then request GPU runners:
jobs:
train:
runs-on: [self-hosted, gpu, cuda]
steps:
- uses: actions/checkout@v4
- name: Train model
run: |
nvidia-smi # Verify GPU access
python train.py --epochs 100
For bare metal GPU servers (the more common case for serious ML work), the setup is similar to regular Linux runners, but make sure you have:
- NVIDIA drivers installed
- CUDA toolkit if needed
- nvidia-container-toolkit for Docker GPU access
# Verify GPU access
nvidia-smi
# Configure runner with GPU label
./config.sh \
--url "$GITHUB_URL" \
--token "$TOKEN" \
--labels "linux,gpu,cuda-12" \
--unattended
The macOS Problem: iOS Builds
I left this for its own section because it’s where self-hosting really shines—and where the setup gets annoying.
GitHub charges 10x for macOS minutes compared to Linux. If you’re building iOS apps, this adds up fast. A full build+test cycle might take 15-20 minutes. Do that a few times per PR across an active team, and you’re looking at serious money.
The solution: Mac Minis in a closet. I’m only half joking.
Here’s a real setup I’ve seen work well:
#!/bin/bash
# mac-runner-setup.sh - Run on each Mac Mini
# Prerequisites:
# - macOS 13+ (Ventura or later)
# - Xcode installed from App Store
# - Apple Silicon (M1/M2/M3)
# - Auto-login enabled for runner user
set -e
RUNNER_VERSION="2.314.1"
RUNNER_USER="buildbot"
GITHUB_URL="https://github.com/your-org"
# Create runner user if doesn't exist
if ! id "$RUNNER_USER" &>/dev/null; then
sudo sysadminctl -addUser $RUNNER_USER -password "CHANGEME" -admin
fi
# System settings for CI
# Disable sleep
sudo pmset -a sleep 0
sudo pmset -a hibernatemode 0
sudo pmset -a disablesleep 1
# Accept Xcode license
sudo xcodebuild -license accept
# Install Homebrew (as runner user)
sudo -u $RUNNER_USER /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install common tools
sudo -u $RUNNER_USER brew install swiftlint fastlane cocoapods
# Setup runner
RUNNER_DIR="/Users/$RUNNER_USER/actions-runner"
sudo -u $RUNNER_USER mkdir -p $RUNNER_DIR
cd $RUNNER_DIR
sudo -u $RUNNER_USER curl -o actions-runner.tar.gz -L \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-osx-arm64-${RUNNER_VERSION}.tar.gz"
sudo -u $RUNNER_USER tar xzf actions-runner.tar.gz
rm actions-runner.tar.gz
# Get registration token (you'd script this differently in practice)
echo "Get token from GitHub and run:"
echo "sudo -u $RUNNER_USER ./config.sh --url $GITHUB_URL --token TOKEN --labels macos,arm64,ios,xcode15"
Critical macOS-specific settings:
# Enable auto-login (System Settings → Users & Groups → Automatic login)
# Or via command line:
sudo defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser "$RUNNER_USER"
# Disable screen saver and lock
sudo -u $RUNNER_USER defaults -currentHost write com.apple.screensaver idleTime 0
# Keep system awake
caffeinate -d -i -s &
# Trust your signing certificates
# (Import from .p12 file into login keychain)
security import distribution.p12 -P "PASSWORD" -A -t cert -f pkcs12 -k ~/Library/Keychains/login.keychain-db
security set-key-partition-list -S apple-tool:,apple: -s -k "CHANGEME" ~/Library/Keychains/login.keychain-db
A workflow for iOS builds:
name: iOS Build
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: [self-hosted, macos, arm64, ios]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.2.app
- name: Install dependencies
run: |
bundle install
pod install
- name: Build
run: |
xcodebuild -workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-configuration Debug \
build
- name: Test
run: |
xcodebuild -workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-configuration Debug \
test
The economics work out nicely. A Mac Mini M2 costs around $600. At GitHub’s macOS rates, that’s roughly 3-4 months of heavy usage. After that, it’s essentially free compute (minus electricity and your time).
Security Considerations
Self-hosted runners introduce security risks that GitHub-hosted runners don’t have. The runner executes arbitrary code from your workflows—and potentially from fork PRs if you’re not careful.
Don’t Run Untrusted Code
By default, pull requests from forks can trigger workflows. On a self-hosted runner, this means someone could submit a PR that runs rm -rf / or worse.
Protect yourself:
# Only run on PRs from the same repo, not forks
on:
pull_request:
types: [opened, synchronize]
jobs:
build:
# This prevents fork PRs from running on self-hosted
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: [self-hosted, linux]
Or require approval for fork workflows in your repository settings.
Network Isolation
Self-hosted runners should be on an isolated network segment. They don’t need access to your production databases or internal services—unless your workflows specifically require it.
# Example: Runner in a DMZ with limited access
# Firewall rules would allow:
# - Outbound HTTPS to github.com and your package registries
# - Inbound nothing
# - Access to specific internal services as needed
Secret Management
Runners can access repository secrets. Be thoughtful about what secrets you expose:
jobs:
deploy:
runs-on: [self-hosted, linux]
environment: production # Requires approval
steps:
- name: Deploy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./deploy.sh
Use environments with protection rules for sensitive deployments. Require reviews before jobs can access production secrets.
Ephemeral Over Persistent
Ephemeral runners are more secure because they start clean every time. No leftover credentials, no state from previous jobs, no accumulated cruft.
If you must use persistent runners, clean up aggressively:
jobs:
build:
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
with:
clean: true # Remove untracked files
# ... your build steps ...
- name: Cleanup
if: always()
run: |
# Clear workspace
rm -rf ${{ github.workspace }}/*
# Clear temp files
rm -rf /tmp/runner-*
# Clear Docker (if used)
docker system prune -af || true
Runner Groups
At the organization level, you can create runner groups with access controls:
Organization Settings → Actions → Runner groups
Group: "production-deploy"
- Runners: [prod-deploy-01, prod-deploy-02]
- Access: Only "infrastructure" and "platform" repos
Group: "general-ci"
- Runners: [ci-01, ci-02, ci-03, ci-04]
- Access: All repositories
This prevents random repos from running jobs on your production deployment runners.
Monitoring and Observability
Runners can go offline. They can fill up their disks. They can get stuck. You need visibility into what’s happening.
Basic Health Checks
A simple monitoring script:
#!/bin/bash
# runner-health.sh - Run via cron
RUNNERS_DIR="/opt/actions-runners"
ALERT_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
check_runner() {
local runner_dir=$1
local runner_name=$(basename $runner_dir)
# Check if service is running
if ! systemctl is-active --quiet "actions.runner.*.${runner_name}.service"; then
alert "Runner $runner_name service is not running"
return 1
fi
# Check disk space
local usage=$(df $runner_dir --output=pcent | tail -1 | tr -d '% ')
if [ $usage -gt 80 ]; then
alert "Runner $runner_name disk usage at ${usage}%"
fi
# Check if runner is online (via GitHub API)
# ... API call to check runner status
}
alert() {
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"🚨 $1\"}" \
$ALERT_WEBHOOK
}
for runner in $RUNNERS_DIR/*; do
check_runner $runner
done
Prometheus Metrics
For Kubernetes runners, the Actions Runner Controller exposes Prometheus metrics:
# ServiceMonitor for Prometheus Operator
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: actions-runner-controller
namespace: actions-runner-system
spec:
selector:
matchLabels:
app.kubernetes.io/name: actions-runner-controller
endpoints:
- port: metrics
interval: 30s
Key metrics to watch:
actions_runner_controller_running_runners- Currently active runnersactions_runner_controller_pending_runners- Runners waiting to startactions_runner_controller_workflow_run_duration_seconds- Job execution time
A Grafana dashboard query:
# Queue depth over time
sum(actions_runner_controller_pending_runners) by (runner_deployment)
# Runner utilization
sum(actions_runner_controller_running_runners) / sum(actions_runner_controller_desired_replicas)
Log Aggregation
Ship runner logs to your logging platform:
# Fluent Bit config for runner logs
[INPUT]
Name tail
Path /opt/actions-runner/_diag/*.log
Tag runner.*
Multiline On
Parser_Firstline multiline_runner
[OUTPUT]
Name es
Match runner.*
Host elasticsearch.internal
Port 9200
Index github-runners
Hybrid Setups
You don’t have to go all-in on self-hosted. A hybrid approach often makes the most sense:
- GitHub-hosted for standard CI (tests, linting, small builds)
- Self-hosted for expensive or specialized jobs (iOS builds, GPU work, long-running tests)
jobs:
lint:
runs-on: ubuntu-latest # GitHub-hosted, cheap
steps:
- uses: actions/checkout@v4
- run: npm run lint
test:
runs-on: ubuntu-latest # GitHub-hosted
steps:
- uses: actions/checkout@v4
- run: npm test
build-ios:
needs: [lint, test] # Only run if checks pass
runs-on: [self-hosted, macos, ios] # Self-hosted, expensive
steps:
- uses: actions/checkout@v4
- run: xcodebuild ...
build-android:
needs: [lint, test]
runs-on: ubuntu-latest # GitHub-hosted is fine for Android
steps:
- uses: actions/checkout@v4
- run: ./gradlew assembleRelease
This pattern keeps costs down while still leveraging self-hosted runners where they matter.
Cost Analysis: When Does Self-Hosting Make Sense?
Let me break down the math.
GitHub-hosted pricing (as of early 2026):
- Linux: $0.008/minute
- Windows: $0.016/minute
- macOS: $0.08/minute
Self-hosted costs:
- Hardware (amortized)
- Electricity
- Network
- Your time for maintenance
Here’s a rough comparison for different scenarios:
| Scenario | GitHub Monthly | Self-Hosted Monthly | Break-even |
|---|---|---|---|
| 10,000 Linux minutes | $80 | ~$50 (cloud VM) | Never (GitHub easier) |
| 50,000 Linux minutes | $400 | ~$100 (dedicated) | 3-4 months |
| 10,000 macOS minutes | $800 | ~$50 (Mac Mini power) | 1 month |
| GPU workloads | N/A | $200-500 (cloud GPU) | Immediately (no GitHub option) |
The crossover point for Linux is around 30,000-50,000 minutes/month. Below that, GitHub-hosted is usually easier and comparable in cost.
For macOS, self-hosting almost always makes sense if you have consistent volume. The 10x pricing difference is brutal.
For GPUs and specialized hardware, self-hosting is your only real option.
Real-World Example: A Complete Setup
Let me tie this all together with a realistic setup for a mid-sized company:
The situation:
- 50 developers
- Monorepo with multiple services
- iOS and Android apps
- Some ML components
- Moderate security requirements
The architecture:
┌─────────────────────────────────────────────────────────────┐
│ GitHub Organization │
├─────────────────────────────────────────────────────────────┤
│ │
│ GitHub-Hosted Runners Self-Hosted Runners │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ ubuntu-latest │ │ Kubernetes Cluster │ │
│ │ - Linting │ │ ┌─────────────────────┐ │ │
│ │ - Unit tests │ │ │ General pool (1-20) │ │ │
│ │ - Small builds │ │ │ Large pool (0-10) │ │ │
│ └─────────────────┘ │ │ Docker pool (0-5) │ │ │
│ │ └─────────────────────┘ │ │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ Mac Mini Cluster │ │
│ │ - mac-build-01 │ │
│ │ - mac-build-02 │ │
│ │ - mac-build-03 │ │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ GPU Server │ │
│ │ - 2x NVIDIA A100 │ │
│ │ - ML training/inference │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Runner groups:
# General CI - all repos
- name: "general-ci"
runners: [k8s-general-pool]
access: all_repositories
# iOS builds - mobile team repos only
- name: "ios-builds"
runners: [mac-build-01, mac-build-02, mac-build-03]
access: [ios-app, shared-mobile-libs]
# ML workloads - data science repos only
- name: "gpu-compute"
runners: [gpu-server-01]
access: [ml-models, recommendation-engine]
# Production deploys - infrastructure repos only
- name: "production"
runners: [k8s-deploy-pool]
access: [infrastructure, platform]
Sample workflow using this setup:
name: Full CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
test-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
test-integration:
runs-on: [self-hosted, linux, large]
needs: test-unit
steps:
- uses: actions/checkout@v4
- run: docker-compose up -d
- run: npm run test:integration
- run: docker-compose down
build-ios:
runs-on: [self-hosted, macos, ios]
needs: [lint, test-unit]
steps:
- uses: actions/checkout@v4
- run: bundle install
- run: bundle exec fastlane build
build-android:
runs-on: ubuntu-latest
needs: [lint, test-unit]
steps:
- uses: actions/checkout@v4
- run: ./gradlew assembleRelease
ml-tests:
runs-on: [self-hosted, gpu, cuda]
needs: test-unit
steps:
- uses: actions/checkout@v4
- run: python -m pytest tests/ml/ --gpu
deploy:
runs-on: [self-hosted, linux, deploy]
needs: [test-integration, build-ios, build-android, ml-tests]
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh production
Wrapping Up
Self-hosted runners are a tool. Like any tool, they solve specific problems. If GitHub’s runners work for you, stick with them—less to manage, less to break.
But when you hit the limits—cost, performance, hardware requirements, security—self-hosting opens up possibilities. iOS builds become affordable. GPU workloads become possible. Your network becomes accessible.
The key is starting simple. One runner on a spare machine. See how it goes. Then scale up as needed—more runners, Kubernetes orchestration, monitoring, the whole deal.
I’ve seen teams save thousands of dollars a month by self-hosting their iOS builds alone. I’ve also seen teams waste weeks on infrastructure they didn’t need. Know your requirements, do the math, and pick the approach that makes sense for your situation.
Next up in this series: real-world case studies—complete workflows from production, with all the rough edges and hard-won lessons included.