Every chapter so far has routed the heavy jobs to "the Mac mini" as if it were a solved thing — Ch 1 drew it in the fleet diagram, Ch 3 sent runs-on: [self-hosted, macos] at it. This chapter opens the box and builds it for real, because a self-hosted runner is the one piece of this whole series that puts your signing identity on a machine that runs triggered code. Done carelessly that's a security hole. Done well it's the cheapest, fastest Apple build infrastructure an indie shop can own.
The setup we're building is the one a lot of small teams actually have: one always-on Mac mini and a few developer MacBook Pros. By the end you'll have registered the mini as a runner, hardened it so it's safe to hold your certs, wired private networking so you and your agents can reach it without exposing it to the internet, and have a plan for the day one mini isn't enough.
What a Self-Hosted Runner Actually Is
A GitHub Actions runner is a small agent program that does one thing in a loop: ask GitHub "got any jobs for me?", and when the answer is yes, run them and report back. The crucial detail — and the one that makes this whole thing safe and easy — is the direction:
The runner connects outbound to GitHub over HTTPS and long-polls for work. GitHub never connects in to your mini. So the mini needs no public IP, no port forwarding, no inbound firewall holes — it can sit behind your home router's NAT and still receive jobs.
That single fact removes most of what people fear about self-hosting. You are not exposing a server to the internet. You're running a polling client that reaches out, exactly like your laptop reaching out to fetch email.
Figure 1 — The runner reaches out; GitHub never reaches in. Because the connection is outbound, the mini is invisible to the public internet — there's nothing to port-scan, nothing to firewall open. This is why a Mac mini on a shelf at home is a perfectly good CI runner.
Registering the Mini
The registration is a five-minute sequence. Do it inside a dedicated macOS user account on the mini (we'll justify that in the hardening section — do it from the start so you don't have to redo it):
# On the Mac mini, signed in as the dedicated 'runner' user:
mkdir ~/actions-runner && cd ~/actions-runner
# Download the macOS arm64 runner (get the current URL + token from
# GitHub → repo → Settings → Actions → Runners → New self-hosted runner)
curl -o runner.tar.gz -L https://github.com/actions/runner/releases/download/vX.Y.Z/actions-runner-osx-arm64-X.Y.Z.tar.gz
tar xzf runner.tar.gz
# Configure: point it at your repo, give it labels, name it
./config.sh \
--url https://github.com/OWNER/REPO \
--token <REGISTRATION_TOKEN> \
--labels self-hosted,macos,apple-silicon \
--name mac-mini-01
# Install + start as a launchd service so it survives reboots and logout
./svc.sh install
./svc.sh startThe --labels are what Ch 3's runs-on: [self-hosted, macos] matches against. The svc.sh install step is the one people skip and regret — without it the runner only runs while a terminal is open. As a launchd service it comes back after a power cut, an OS update, or a reboot, with no one logged in.
Hardening: Making One Machine Safe to Trust
Here's the heart of it. From Ch 1, the mini is the one high-trust machine — it holds the signing identity and deploy tokens that the laptops deliberately don't. That concentration is the design: a small, hardened blast radius. But "holds the secrets" and "runs triggered code" in the same box is only safe if you build the fence. Five controls do almost all the work:
| Control | What you do | What it stops |
|---|---|---|
| Private repos only | Never attach the runner to a public repo or let fork PRs trigger it. | A stranger opening a PR and running their code on your signing box. The non-negotiable. |
| Owner-gated workflows | if: github.actor == 'you' and workflow_dispatch for anything that signs/deploys. | An unexpected or automated trigger running privileged steps unattended. |
| Dedicated runner user | Run the runner as a separate, non-admin macOS account — not your daily login. | A bad job reaching your personal files, browser sessions, or login keychain. |
| Isolated signing keychain | Put certs in a dedicated keychain, unlocked per-job and re-locked after — not the login keychain. | Every job having standing access to your signing identity. |
| Least-privilege token | permissions: contents: read in the workflow; no secrets in jobs that don't sign. | A compile job that's been tampered with from also being able to push or publish. |
This isn't theoretical — it's exactly the posture of this repo's real macos-build.yml, which is workflow_dispatch-only, gated with if: github.actor == 'williamjiamin', runs with permissions: contents: read, and carries no signing secrets because the runner host is a personal machine. The file is commented top-to-bottom with these reasons. That's the template: assume the runner host is precious, and constrain every workflow accordingly.
Networking: Reach the Mini Without Exposing It
The runner needs nothing inbound. But you — and your agents, and the mini's own services — often need to reach the mini: to SSH in, to hit the planned SwiftUI preview renderer (the Mac-mini render service noted in CLAUDE.md), to debug a stuck job. The wrong way is port-forwarding on your router and exposing SSH or a service to the public internet. The right way is a private mesh network — Tailscale (WireGuard under the hood):
# On the mini AND each MacBook Pro:
brew install --cask tailscale
tailscale up # log in once; the device joins your private tailnet
# Now, from any MacBook on the tailnet, the mini is reachable by name —
# over an encrypted link, with no public exposure:
ssh runner@mac-mini-01Every device gets a stable private address that only your other devices can reach. The mini's services bind to the tailnet interface, not 0.0.0.0 — so the preview renderer is reachable by your laptops and CI, and invisible to everyone else.
Figure 2 — Private mesh, public reach only where intended. Laptops and the mini talk over an encrypted tailnet; the mini's services are never exposed publicly. The only public traffic is the mini reaching out to GitHub, Cloudflare, and the app stores. Your build cluster has no public attack surface.
When One Mini Isn't Enough
A single mini is a single lane: jobs requiring [self-hosted, macos] queue behind each other, and if the mini is down, Apple builds stop. That's fine for a solo shop shipping a few times a week. When it isn't, you scale in the order of cheapest-first:
Figure 3 — Scale cheapest-first. Most shops never leave step 1.
- Add a second runner. Register another Mac — or one of the MacBook Pros — with the same
self-hosted,macoslabels. Now two Apple jobs run in parallel. A laptop makes a good occasional runner; just remember it's also a low-trust authoring machine, so don't put the primary signing identity on it. - Use ephemeral runners for laptops. A laptop registered with
--ephemeralpicks up exactly one job, runs it on clean state, then deregisters. No long-lived runner state accumulating on a machine someone also uses day-to-day:./config.sh --url ... --token ... --labels self-hosted,macos --ephemeral - Burst to cloud for overflow. Keep
macos-latestas a fallback label for non-signing jobs (tests, compile checks) so a flood of work can spill into GitHub's hosted Macs — you pay the 10x only for the overflow, and never for the steady state.
Keeping It Healthy
A build machine you never look at rots quietly. Four habits keep the mini reliable:
| Habit | Why |
|---|---|
| Pin the Xcode version | Use xcodes (or xcode-select) to fix the exact Xcode the mini builds with, matching what you'd submit. "Works on my Xcode, broke in CI" is a version drift you control here. |
| Watch disk space | DerivedData, simulators, and old artifacts grow without bound. A full disk fails builds in confusing ways. Clean DerivedData on a schedule. |
| Monitor runner status | GitHub shows the runner Online/Offline in Settings → Runners. If it goes offline (reboot, network blip, expired token), Apple builds silently queue forever. Glance at it, or alert on it. |
| Keep the toolchain current | The runner auto-updates; Xcode, Node, and fastlane don't. Update them deliberately and re-run a build to confirm nothing broke before you need a release. |
These four are also the backbone of an onboarding guide for any future project that wants to use this mini — register with the standard labels, run as the dedicated user, keep signing in the isolated keychain, reach it over Tailscale, and respect the pinned toolchain. A new repo that follows those rules inherits a hardened, working build box on day one instead of rebuilding the security model from scratch.
What This Project Actually Does
Honest status, same as every chapter: the mini is registered and serving the [self-hosted, macos] label via macos-build.yml, which today runs a constrained, owner-gated, secret-free compile check (CODE_SIGNING_ALLOWED=NO). So the runner and the security posture in this chapter are real and live; what's not yet wired is the full signed-and-notarized release running on it.
- Live: outbound-polling runner on the mini;
workflow_dispatch-only, owner-gated, read-only-token workflow; the mini also slated to host the SwiftUI preview render service (tailnet-only is the right exposure for it). - The gap to the target: the isolated signing keychain + per-job unlock, the notarize-and-deploy steps moving onto the mini (today the notarized release is built by hand per CLAUDE.md), and a second/ephemeral runner if cadence ever demands it.
The summary: the hard, scary part — a trusted machine running triggered code safely — is already set up correctly. Promoting it from "compile check" to "full signed release" is additive, not a redesign.
Mental Model — Three Sentences
- A self-hosted runner polls GitHub outbound, so your Mac mini needs no open ports and has no public attack surface — it's a polling client behind your NAT, not a server you expose.
- The mini is safe to hold your signing identity only because of the fence around it: private repos only, owner-gated
workflow_dispatch, a dedicated non-admin runner user, an isolated per-job signing keychain, and least-privilege tokens — exactly the posture of this repo's realmacos-build.yml. - Reach the mini over a private Tailscale mesh (never port-forwarding), bind its services to the tailnet, and scale cheapest-first — one mini, then an ephemeral laptop runner, then cloud burst for overflow — keeping the signing identity on the one hardened machine throughout.
Try It Yourself (20 Minutes)
- Register a runner. On any spare Mac, follow the registration steps against a private test repo. Run
./svc.sh install && ./svc.sh startand watch it appear as Online in Settings → Runners. - Prove the outbound model. Note that you never opened a port or forwarded anything — yet the runner picks up jobs. That's the long-poll. Internalise that there's nothing inbound to secure.
- Lock down a workflow. Add
permissions: contents: read,on: workflow_dispatch, and anif: github.actor == 'YOU'gate to a workflow targeting your runner. You just made it impossible for anyone but you to run code on that machine. - Mesh it. Install Tailscale on the Mac and your laptop,
tailscale upon both, andsshto the Mac by its tailnet name. No public IP involved — that's how you'll reach the mini and its services forever.
Where This Lands in the Series
The cluster is real now: a registered, hardened, privately-networked mini that the cross-platform CI from Ch 3 can safely send Apple builds to, with a clear path to scale. The infrastructure is built.
The next two chapters spend it. Ch 5 takes the web half all the way to production on Cloudflare — preview deploys on every PR, the staging→production promotion that carries one artifact forward, and the instant rollback that makes a bad deploy a non-event. Then Ch 6 does the same for the app half across all four native targets. The fleet exists; now we ship from it.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper