Skip to main content

Self-Hosted GitHub Actions Runner — Mac Mini

Problem: GitHub Free plan includes 2,000 Actions minutes/month. We used 2,815 in March — all CI is blocked. Solution: Self-hosted runner on the Mac mini. Unlimited minutes, $0/month, isolated from personal credentials.


Architecture

┌─────────────────────────────────────────────────────┐
│ Mac Mini (24GB RAM, 12 CPU cores, macOS) │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Docker: github-runner (isolated container) │ │
│ │ │ │
│ │ - Runs as: dedicated 'runner' user │ │
│ │ - No access to host filesystem │ │
│ │ - No access to Ivan's credentials │ │
│ │ - Own 1Password service account (optional) │ │
│ │ - GitHub runner token (scoped to org) │ │
│ │ - Labels: self-hosted, linux, x64 │ │
│ │ │ │
│ │ Volumes: │ │
│ │ - runner-work:/home/runner/work (ephemeral) │ │
│ │ - runner-tmp:/tmp (ephemeral) │ │
│ │ - Docker socket: NO (use Docker-in-Docker) │ │
│ │ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
│ │sv0-blue │ │sv0-echo │ │sv0-delta│ │sv0-charlie│ │
│ │ (bot) │ │ (bot) │ │ (bot) │ │ (bot) │ │
│ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ sv0-platform (API + UI + MongoDB) │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘

Isolation Model

The runner MUST NOT have access to:

  • Ivan's SSH keys, GPG keys, or personal credentials
  • 1Password CLI with Ivan's vault access
  • The bot containers or their volumes
  • The host Docker socket directly (prevents container escape)
  • The sv0-platform dev instance or its MongoDB

The runner CAN have access to:

  • GitHub org token (auto-provided by Actions runner registration)
  • Environment secrets passed through GitHub Actions workflows (isolated per workflow run)
  • Its own 1Password service account (if needed for deployment secrets)
  • Network access (to pull dependencies, push to registries)
  • Docker-in-Docker (for building images inside the runner container)

Implementation

Use myoung34/github-runner or the official actions/runner image in a Docker container. This is the most isolated approach — the runner is a disposable container with no host access.

# docker-compose.runner.yml
services:
github-runner:
image: myoung34/github-runner:latest
restart: unless-stopped
environment:
RUNNER_SCOPE: org
ORG_NAME: SecurityV0
RUNNER_TOKEN: ${RUNNER_TOKEN} # from 1Password or manual registration
RUNNER_NAME: mac-mini-runner
RUNNER_WORKDIR: /home/runner/work
LABELS: self-hosted,linux,x64,mac-mini
RUNNER_GROUP: default
EPHEMERAL: "false" # persistent runner, not one-shot
volumes:
- runner-work:/home/runner/work
- /var/run/docker.sock:/var/run/docker.sock # see security note
deploy:
resources:
limits:
cpus: '4'
memory: 8G
reservations:
cpus: '2'
memory: 4G

volumes:
runner-work:

Security note on Docker socket: Mounting the host Docker socket gives the runner container full Docker access (it could inspect other containers). For better isolation, use Docker-in-Docker (DinD) instead:

    # Replace docker.sock mount with DinD sidecar
environment:
DOCKER_HOST: tcp://dind:2376
DOCKER_TLS_VERIFY: 1
depends_on:
- dind

dind:
image: docker:dind
privileged: true
environment:
DOCKER_TLS_CERTDIR: /certs
volumes:
- dind-certs:/certs
- runner-work:/home/runner/work

volumes:
runner-work:
dind-certs:

Option B: LaunchAgent (Simpler, Less Isolated)

Run the GitHub Actions runner as a macOS LaunchAgent under a dedicated _ghrunner user. Simpler setup but less isolated — the runner process shares the macOS kernel and can see other processes.

Not recommended given you already have Docker infrastructure.

Setup Steps

1. Register the runner with GitHub

# Get org-level runner registration token
gh api -X POST /orgs/SecurityV0/actions/runners/registration-token --jq '.token'

Save this token — it expires in 1 hour.

2. Create the Docker Compose file

mkdir -p ~/dev/securityv0/infra/github-runner
# Write docker-compose.runner.yml (see above)

3. Store the runner token in 1Password

Create a 1Password item sv0-runner/github-runner-token with the registration token. The compose file reads it via op:// reference or env var.

4. Start the runner

cd ~/dev/securityv0/infra/github-runner
RUNNER_TOKEN=$(gh api -X POST /orgs/SecurityV0/actions/runners/registration-token --jq '.token')
docker compose -f docker-compose.runner.yml up -d

5. Verify registration

gh api /orgs/SecurityV0/actions/runners --jq '.runners[] | {name, status, os, labels: [.labels[].name]}'

6. Update workflows to use the runner

Add self-hosted to workflow runs-on:

jobs:
build-test:
runs-on: [self-hosted, linux, x64] # was: ubuntu-latest

Or keep ubuntu-latest as fallback and add a matrix:

jobs:
build-test:
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}

Set RUNNER_LABEL=self-hosted as an org-level variable — all workflows switch at once.

Resource Allocation

Current Mac mini load with 8 containers:

  • 4 bot containers (mostly idle, spike during reviews)
  • 3 sv0-platform containers (API + UI + MongoDB)
  • 1 n8n container

Proposed runner limits: 4 CPUs, 8 GB RAM. This leaves 8 CPUs and 16 GB for existing workloads. CI jobs typically need 2-4 GB RAM and burst CPU for npm install / build / test — well within limits.

Maintenance

  • Runner updates: The myoung34/github-runner image auto-updates the runner binary. Restart the container monthly to pick up image updates.
  • Disk cleanup: Runner work directory grows with each job. Add a cron job or use Docker's --rm flag for ephemeral mode.
  • Token rotation: GitHub runner tokens are long-lived after registration. No rotation needed unless you re-register.
  • Monitoring: Check runner status via gh api /orgs/SecurityV0/actions/runners. Add a health check to your existing monitoring if applicable.

Workflow Optimization (Regardless of Runner Choice)

Even with a self-hosted runner, optimize to reduce unnecessary CI time:

  1. Visual Review workflow — only trigger on PRs that change ui/ files:

    on:
    pull_request:
    paths: ['ui/**', 'scripts/visual-*']
  2. Deploy-dev workflow — only trigger on main push, not every PR

  3. Cache node_modules — both npm ci steps should use actions/cache or setup-node cache

Cost Comparison

ApproachMonthly CostMinutes Limit
Stay on Free (blocked)$02,000 (exceeded)
Free + self-hosted runner$0Unlimited
Team plan$12 (3 seats)3,000 (barely enough)
Team + self-hosted runner$12Unlimited
Enterprise (if Mercury free)$0 first year50,000
Enterprise after free year$63 (3 seats)50,000

Recommendation: Free + self-hosted runner now. Upgrade plan later for security features (CodeQL, branch protection), not for minutes.