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.
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.
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.
- 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.
- Choose macOS and the right architecture — arm64 for Apple silicon, x64 for Intel.
- 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.
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.
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.
| Persistent | Ephemeral | |
|---|---|---|
| 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.
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.
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 withsudo xcode-select -sso 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.
Primary documentation
- Adding self-hosted runners — registration flow, one-hour token expiry, repository vs organisation scope (checked 2026-08-02).
- Configuring the runner application as a service
— the macOS
svc.shcommands and the plist template (checked 2026-08-02). - Self-hosted runners reference — ephemeral runners, autoscaling guidance, auto-update behaviour, IP allow lists (checked 2026-08-02).
- Managing access to self-hosted runners — runner groups and the public-repository warning quoted above (checked 2026-08-02).
- actions/runner releases — v2.336.0, published 2026-07-20; the SHA-256 above is from those release notes.
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.
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)