GitHub Actions: Self-Hosted Runners

When GitHub's runners aren't enough—whether it's cost, performance, or that Mac Mini in your closet—here's how to run your own infrastructure for Actions.

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 runners
  • actions_runner_controller_pending_runners - Runners waiting to start
  • actions_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:

ScenarioGitHub MonthlySelf-Hosted MonthlyBreak-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 workloadsN/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.