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
Option A: Docker Container (Recommended)
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-runnerimage 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
--rmflag 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:
-
Visual Review workflow — only trigger on PRs that change
ui/files:on:
pull_request:
paths: ['ui/**', 'scripts/visual-*'] -
Deploy-dev workflow — only trigger on main push, not every PR
-
Cache node_modules — both
npm cisteps should use actions/cache or setup-node cache
Cost Comparison
| Approach | Monthly Cost | Minutes Limit |
|---|---|---|
| Stay on Free (blocked) | $0 | 2,000 (exceeded) |
| Free + self-hosted runner | $0 | Unlimited |
| Team plan | $12 (3 seats) | 3,000 (barely enough) |
| Team + self-hosted runner | $12 | Unlimited |
| Enterprise (if Mercury free) | $0 first year | 50,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.