braeburn/guides/runner setup

A self-hosted macOS runner
on a dedicated Mac.

How to attach any dedicated Mac to GitHub Actions as a self-hosted runner: register it, keep it running across reboots with launchd, decide between ephemeral and persistent, and avoid the one security mistake that matters.

Nothing here is Braeburn-specific. These steps work on a Mac mini under your desk, a machine at any hosting provider, or one of ours — it's the same runner software and the same GitHub. We host Macs, so we have an obvious interest, but a guide that only worked on our hardware would be useless to you and would not deserve to rank.

Runner v2.336.0 · verified 2026-08-02
01 / before you start

What you need

  • A Mac you control — physical or hosted, Apple silicon or Intel. It should be dedicated to this job: a runner that shares a machine with someone's desktop session will produce confusing build failures.
  • Admin rights on the repository or organisation. Repository-level runners need repo-owner rights (or admin access for org repos); organisation-level runners need to be an organisation owner.
  • Xcode installed, plus the command line tools, matching the Xcode version your project expects. This is the single most common cause of "works locally, fails on the runner".
  • Outbound HTTPS on port 443. The runner polls GitHub; no inbound ports need to be open, and it does not need a public IP.

Decide the scope first

A runner registered to a repository serves that repository only. A runner registered to an organisation can be shared across repositories via runner groups, which is what you want past two or three projects — re-registering later means tearing down and redoing this. Pick before you run config.sh.

02 / register

Create the runner in GitHub

GitHub generates a registration token tied to your account and repository, so start in the web UI rather than copying a command from anywhere — including this page.

  1. For a repository runner: Settings → Actions → Runners → New self-hosted runner. For an organisation runner: the organisation's Settings → Actions → Runners → New runner → New self-hosted runner.
  2. Choose macOS and the right architecture — arm64 for Apple silicon, x64 for Intel.
  3. GitHub shows a download-and-configure snippet containing your URL and a registration token that expires after one hour. Use that snippet; the commands below show you what it will look like so nothing is a surprise.

Download and verify

The current runner release at the time of writing is v2.336.0. Always take the version GitHub's UI gives you, and check the checksum before extracting:

# on the Mac, in a Terminal session
mkdir actions-runner && cd actions-runner

# Apple silicon build (use osx-x64 on Intel Macs)
curl -O -L https://github.com/actions/runner/releases/download/v2.336.0/actions-runner-osx-arm64-2.336.0.tar.gz

# verify before you extract — GitHub publishes this hash in the release notes
echo "8e8839c49b7060b6b2154f4931f815df330c27f167d53ef2239ee3dfce28b079  actions-runner-osx-arm64-2.336.0.tar.gz" | shasum -a 256 -c

tar xzf ./actions-runner-osx-arm64-2.336.0.tar.gz

Configure

Now register the machine. Replace the URL with your repository or organisation and paste the token from GitHub's UI:

# repository runner
./config.sh --url https://github.com/YOUR-ORG/YOUR-REPO --token YOUR-TOKEN

# organisation runner (shared across repos via runner groups)
./config.sh --url https://github.com/YOUR-ORG --token YOUR-TOKEN

config.sh asks for a runner group, a name, and labels. Labels are how workflows select this machine — something like macos,xcode-16,m4 is far more useful later than accepting the defaults.

Run it once, interactively

./run.sh

You should see √ Connected to GitHub followed by Listening for Jobs, and the runner should appear as idle in the same Settings → Actions → Runners list. Push a workflow that targets it to confirm end to end:

jobs:
  build:
    runs-on: self-hosted        # or: [self-hosted, macOS, ARM64, xcode-16]
    steps:
      - uses: actions/checkout@v5
      - run: xcodebuild -version

Once a job has run green, stop the interactive process with Ctrl+C — it's time to make it permanent.

03 / survive reboots

Run it as a launchd service

./run.sh dies with your SSH session. The runner ships a helper that installs it as a launchd LaunchAgent, so it starts automatically when the machine boots:

# from the actions-runner directory — note: NO sudo on macOS
./svc.sh install
./svc.sh start
./svc.sh status

Don't use sudo here

Nearly every third-party guide writes sudo ./svc.sh install. That is the Linux/systemd form. On macOS the runner installs as a per-user LaunchAgent and the commands take no sudo — using it installs the service against the wrong user and produces a runner that works until the next reboot and then quietly doesn't. GitHub's own documentation shows the macOS commands without sudo.

To stop or remove it later:

./svc.sh stop
./svc.sh uninstall

The auto-login caveat

Because it's a LaunchAgent rather than a system daemon, the runner runs in a user session — and some build steps genuinely need one. Anything touching the keychain (code signing), the window server, or a simulator UI will behave differently or fail outright without a logged-in graphical session. On a headless hosted Mac that means enabling automatic login for the runner user so a session exists after every reboot.

Be clear-eyed about the trade: auto-login means the disk is effectively unlocked whenever the machine boots. On a dedicated build box holding only CI credentials this is the normal, accepted compromise; on a machine holding anything else it is not. Either way, treat the runner's keychain as a secret store and give it its own Apple ID and signing identity rather than a human's.

If you need a customised service instead of the default, the runner ships a template at actions-runner/bin/actions.runner.plist.template. GitHub's requirement is that any custom service must still invoke the runner through runsvc.sh.

04 / ephemeral vs persistent

One job, then gone — or always on?

By default a self-hosted runner is persistent: it takes job after job, and everything a job leaves behind is still there for the next one. That is both the main advantage and the main hazard.

 PersistentEphemeral
State between jobs Kept — warm caches, DerivedData, cloned repos Discarded — one job per registration
Build speed Faster; caches survive Slower cold starts unless you cache externally
Blast radius of a bad job Leaks into every later job on that box Contained to the one job
Operational cost Install once, mostly forget Needs automation to re-register after each job
Autoscaling GitHub advises against it GitHub's recommended approach

To register a runner as ephemeral, add the flag at config time:

./config.sh --url https://github.com/YOUR-ORG --token YOUR-TOKEN --ephemeral

The runner then accepts exactly one job and unregisters itself, which is what makes "a clean environment for each job" possible and limits the exposure of anything sensitive left over from a previous run. GitHub explicitly recommends ephemeral runners for autoscaling, because with persistent runners it can't guarantee that a job isn't assigned to a machine that is being shut down.

The practical shape on a single dedicated Mac

Ephemeral wants automation around it: something must re-run config.sh with a fresh token after every job. On one box that is usually a wrapper script driven by launchd, and it costs you build-cache warmth. Most small teams with a single dedicated Mac and only trusted, private repositories run persistent and are right to. Reach for ephemeral when jobs handle credentials you don't want lingering, or when you're scaling past one machine. And if you go ephemeral, forward the runner logs somewhere external first — GitHub warns that the machine's local logs disappear with the runner, which is exactly when you need them.

05 / security

The mistake that actually hurts

Never attach a self-hosted runner to a public repository

GitHub's own wording: "We recommend that you only use self-hosted runners with private repositories. This is because forks of your public repository can potentially run dangerous code on your self-hosted runner machine by creating a pull request that executes the code in a workflow."

This is not a theoretical warning. Anyone on the internet can fork a public repository, open a pull request that changes the workflow file, and have your machine execute it — with your signing keys, your network position and your credentials. On a hosted Mac that is also someone else's infrastructure you're putting at risk.

The rest of the hygiene, in rough order of value:

  • Keep runners in runner groups with a restrictive access policy. By default only private repositories can use a runner group; leave that default alone. Grant repositories explicitly rather than opening the group to the whole organisation.
  • Give the runner its own macOS user account, not an administrator's, and its own Apple ID and signing identity. Revoking one CI identity should never mean rotating a developer's.
  • Treat the machine as compromised-by-default after untrusted code runs. That's the argument for ephemeral runners when you can't fully trust every contributor.
  • Restrict which workflows can use the runner if your plan supports it, and review workflow changes in pull requests as carefully as application code — a workflow file is remote code execution by design.
  • Don't put long-lived cloud credentials on the box. Prefer short-lived tokens minted per job (OIDC) so a leak has an expiry date.
  • If you use an IP allow list on your organisation, add the runner's outbound IP or it will silently fail to register.
06 / upkeep

Keeping it healthy

Xcode updates are your job now
GitHub-hosted runners get new Xcode images automatically; yours does not. When Apple requires a newer Xcode for App Store submission — which happens on their schedule, not yours — you update the machine. Install via Apple's developer downloads or xcodes, keep the previous version until the new one has built green, and pin the active one explicitly with sudo xcode-select -s so a job never silently picks a different toolchain. Accept the licence once (sudo xcodebuild -license accept) or every build fails.
Runner software updates itself — usually
Self-hosted runners auto-update to new runner versions by default. That's the right setting for a persistent box. If you build container or image-based ephemeral runners, turn it off and bake the version into the image instead, so you're not re-downloading an update on every start.
Watch the disk
DerivedData, simulator runtimes, Homebrew caches and old workspaces fill a 256 GB SSD faster than anyone expects, and a full disk presents as bizarre, intermittent build failures rather than a clear error. Prune old workspaces on a schedule and alert on free space — this is the single most common failure mode of a long-lived Mac runner.
Know your reboot story
Macs have no IPMI, so remote power-cycling depends on your provider's KVM or smart PDU. Test that you can recover from a wedged machine before you need to, and confirm the runner comes back by itself after a hard reboot — that's the whole point of the launchd service, and it's worth verifying once rather than assuming.
Log where you can see it
Runner logs live in _diag/ in the runner directory. Check them first when a job behaves strangely; they capture registration and job-assignment problems that never reach the GitHub web UI.
07 / sources

Primary documentation

Commands on this page were taken from GitHub's current documentation on 2026-08-02, not reproduced from memory or from other guides. Version numbers and hashes go stale — take the download snippet from your repository's own runner page and treat the version here as illustrative.

— WAITLIST —

Need a Mac to put it on?

Dedicated M4 Mac minis in a German datacenter, flat monthly, month-to-month. One email — which plan, how many machines, when.

WAITLIST FREE · NON-BINDING · VAT NOT CHARGED (§ 19 USTG)