1–5 min delivery

Dedicated M4 Runner—No More macOS Queue

$21.5 From /day · Dedicated physical machine
Configure Cloud Mac
M4 · 16 GB Pin Xcode Version launchd Persistence

FIELD NOTE · iOS Build

GitHub Actions Self-Hosted Runner on Mac mini M4: iOS CI Setup Guide

No always-on Mac, but need stable iOS Archive builds on GitHub? This post documents our full setup on a dedicated JexMac Mac mini M4 physical node: from first SSH connection, Runner registration and launchd persistence, to workflow label routing, Xcode version pinning, and Distribution certificate import in a headless environment—each step includes reproducible commands and measured timings.

Goal: A Verifiable End-to-End iOS Build Pipeline

Before installing the Runner, we defined what "setup complete" means—so you don't finish Runner install only to get stuck at signing. The finish line for this walkthrough: push to a target branch → GitHub Actions triggers on the dedicated M4 node → checkout code → run xcodebuild archive and successfully produce a .xcarchive. TestFlight upload is out of scope here, but once Archive is stable, fastlane or altool is just an extra step.

The test repo is a mid-size SwiftUI app: ~60 Swift source files, CocoaPods for third-party deps, Release config using Manual signing. Baseline environment: JexMac Mac mini M4 in Singapore (16 GB unified memory, 256 GB NVMe), Xcode 16.2 installed and pinned via xcodes.

4m 08s
Clean Archive (incl. pod install)
< 25s
Self-hosted job: queue to start
0 swap
16 GB RAM, zero swap throughout
1
Dedicated physical machine, no virtualization overselling

Control group: same repo on GitHub-hosted runs-on: macos-14. UTC 13:00–18:00 (Asia-Pacific afternoon): median job queue 11 minutes, longest wait 19 minutes; preinstalled Xcode on macos-14 didn't match local dev machines, triggering Swift 6 compatibility issues. Self-hosting pays off clearly here—wait time drops from minutes to seconds, and Xcode version is pinned on your node.

Three Hidden Costs of Hosted macOS Runners

Many teams pick GitHub-hosted Runners for "zero ops," but in iOS workflows, hidden costs often hurt more than the bill.

First: time cost. GitHub's macOS pool is capacity-limited. Free accounts get 2,000 minutes/month; macOS counts at 10×, so ~200 effective minutes. A project with 8 daily builds at 6 minutes each uses ~1,440 weighted minutes/month—near the free cap; add a nightly build and you exceed it.

Second: environment drift. The macos-latest label switches underlying Xcode as GitHub upgrades infra—"green yesterday, red today" happens often. A workaround is sudo xcode-select in the workflow, but each version switch adds 1–2 minutes and still won't match your local dev machine exactly.

Third: debugging cost. Hosted Runners are destroyed after each job—you can't SSH in to reproduce. Keychain prompts, expired provisioning profiles—issues that need "log in and look"—force repeated pushes in hosted envs. We pushed 7 times for one User interaction is not allowed error before finding a missing partition list.

Test Environment

All commands and timings in this post were run on a dedicated JexMac Mac mini M4 physical node in Singapore, test window late July 2026. Hardware: Apple M4 · 10-core CPU · 16 GB unified memory · 1 Gbps dedicated bandwidth.

After Delivery: First SSH Connection and Environment Baseline

JexMac console usually delivers SSH credentials within 1–5 minutes after payment. Once you have the public IP, set up Ed25519 key access and disable password login before installing the Runner—the Runner process runs as the current macOS user, so SSH security baseline comes first.

  1. 01
    Add SSH public key

    ssh-copy-id -i ~/.ssh/id_ed25519.pub jexmac@<node-ip>

    After passwordless login works, edit /etc/ssh/sshd_config to set PasswordAuthentication no, then restart sshd.

  2. 02
    Install Homebrew and xcodes

    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

    brew install xcodesorg/made/xcodes

    xcodes install 16.2 --experimental-fast-pass installs and pins Xcode 16.2 (adjust version per project).

  3. 03
    Verify build chain

    xcodebuild -version should output Xcode 16.2 and the build number.

    xcodebuild -showsdks | grep iphoneos confirms iOS SDK is available.

  4. 04
    Install CocoaPods (if used)

    sudo gem install cocoapods -n /usr/local/bin

    Run pod install on the node early so Specs repo cache is ready—avoids CI first build stalling on repo update.

After baseline setup, the node should have a dedicated directory (we use ~/ci-runner) for Runner and build artifacts, isolated from daily dev files. We reserve a DerivedData subdirectory and set -derivedDataPath explicitly in workflows to prevent path conflicts when multiple projects run concurrently.

GitHub Side: Runner Token and Label Routing Design

Go to target repo → Settings → Actions → Runners → New self-hosted runner, platform macOS ARM64. The page generates a one-time registration token (1-hour validity) and download link.

Label design directly affects whether workflows route to the right machine. Our naming convention:

  • mac: generic label for all macOS self-hosted nodes
  • m4: Apple Silicon M4 chip, to distinguish from older Intel nodes
  • xcode-16-2: pin Xcode minor version; change label on upgrade, not workflow logic
  • sg: datacenter region (Singapore); use for nearest routing in multi-region setups

The --labels flag in registration writes all labels at once. In the workflow, match with array form:

jobs:
  ios-archive:
    runs-on: [self-hosted, mac, m4, xcode-16-2]
    concurrency:
      group: ios-build-${{ github.ref }}
      cancel-in-progress: true

A concurrency group ensures the same branch won't run two Archives in parallel—on a 16 GB M4 node, dual-project concurrent Clean Builds can run, but DerivedData contention swings single-run time by 30%+. For multiple product lines, split labels (e.g. product-a, product-b) and use separate nodes rather than hard concurrency on one machine.

Security Boundary for Public Repos

Self-hosted Runners on public repos can be triggered by fork PRs—malicious workflows run arbitrary code on your Mac. Enable only on private repos or at Organization level, and run the Runner process under a non-admin account. Production setups should use GitHub Environment protection rules to limit secrets to specified branches.

Install Runner on M4 Node and Configure launchd Persistence

Run the following in an SSH session. Runner version follows GitHub registration page (example here: v2.321.0).

  1. 01
    Download and extract

    mkdir -p ~/ci-runner/actions-runner && cd ~/ci-runner/actions-runner

    curl -o actions-runner-osx-arm64-2.321.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-osx-arm64-2.321.0.tar.gz

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

  2. 02
    Interactive registration

    ./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \ --token YOUR_ONE_TIME_TOKEN \ --name jexmac-m4-sg-01 \ --labels mac,m4,xcode-16-2,sg \ --unattended

    --unattended skips interactive confirmation, suitable for scripted deploy. Default _work folder is fine.

  3. 03
    Install launchd service

    ./svc.sh install

    ./svc.sh start

    Verify: ./svc.sh status should show active (running). Green Online on the repo Runners page means registration succeeded.

launchd auto-starts the Runner after reboot, but one detail: the Runner runs as the macOS user who installed it—that user must have logged in at least once (or enable auto-login), otherwise launchd may not access Keychain. We created dedicated account ci-bot, completed Keychain init on first SSH login, then installed svc.sh—no manual intervention after reboot.

Runner logs live at ~/ci-runner/actions-runner/_diag/; each job produces Worker_*.log. For "job assigned to node but no step output" issues, check RunnerListener logs here first—they're more complete than GitHub UI Annotations.

Minimal Workflow: From checkout to Archive

After registration, create .github/workflows/ios-archive.yml in the repo. Below is our verified minimal version—no TestFlight upload, Archive output only:

name: iOS Archive
on:
  push:
    branches: [main, release/*]
  pull_request:
    branches: [main]

jobs:
  archive:
    runs-on: [self-hosted, mac, m4, xcode-16-2]
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: xcodes select 16.2

      - name: Install pods
        run: pod install --deployment
        working-directory: ios

      - name: Build archive
        run: |
          xcodebuild archive \
            -workspace ios/MyApp.xcworkspace \
            -scheme MyApp \
            -sdk iphoneos \
            -configuration Release \
            -archivePath ./build/MyApp.xcarchive \
            -derivedDataPath ~/ci-runner/DerivedData \
            CODE_SIGN_STYLE=Manual \
            CODE_SIGN_IDENTITY="Apple Distribution: Your Team (TEAMID)" \
            PROVISIONING_PROFILE_SPECIFIER="MyApp AppStore"
        env:
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}

      - name: Upload xcarchive artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyApp-xcarchive
          path: ./build/MyApp.xcarchive
          retention-days: 7

Deliberate design choices:

pod install --deployment locks versions via Podfile.lock, keeping CI and local deps in sync. -derivedDataPath points to a fixed directory on the node—reuse compile cache across builds; incremental builds drop from 4 min to ~1 min 40 sec. upload-artifact uploads xcarchive to GitHub for colleagues without SSH to download and verify—7-day retention is enough for QA spot checks.

Headless Environment: Keychain and Distribution Certificate Import

Archive success depends on CI accessing the Distribution private key without a GUI. Hosted Runners preconfigure system Keychain; self-hosted nodes require your own setup—often why teams stall at "Runner installed but Archive signing fails."

Recommended: create a temporary Keychain per job, import p12, sign immediately, destroy after job—avoids long-term private key storage on disk.

# Add before the "Build archive" step
- name: Import signing certificate
  run: |
    security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
    security set-keychain-settings -t 3600 -u build.keychain

    echo "${{ secrets.CERTIFICATE_P12_BASE64 }}" | base64 --decode > cert.p12
    security import cert.p12 \
      -k build.keychain \
      -P "${{ secrets.P12_PASSWORD }}" \
      -T /usr/bin/codesign \
      -T /usr/bin/xcodebuild

    security set-key-partition-list \
      -S apple-tool:,apple: \
      -s -k "$KEYCHAIN_PASSWORD" build.keychain
    rm -f cert.p12
  env:
    KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}

Configure three variables in GitHub Repository Secrets beforehand: KEYCHAIN_PASSWORD (temporary Keychain password—a random string is fine), CERTIFICATE_P12_BASE64 (Base64-encoded Distribution certificate), P12_PASSWORD (password set when exporting the p12).

Error Keyword Common Root Cause Fix
User interaction is not allowed Private key partition list not set; codesign tries to show UI Re-run set-key-partition-list command (see script above)
errSecItemNotFound Certificate imported to wrong Keychain, or default-keychain not switched Confirm security default-keychain -s build.keychain runs before import
could not find signing certificate CODE_SIGN_IDENTITY string doesn't exactly match Identity in Keychain Run security find-identity -v -p codesigning build.keychain and copy the full name
Provisioning profile doesn't match Provisioning profile expired or Bundle ID / Capability mismatch Regenerate in Apple Developer portal, download, sync via secrets or match

After import, verify quickly in job logs with one command:

security find-identity -v -p codesigning build.keychain | grep Distribution

See 1 valid identities found before the Archive step—saves hours of "signing failed but don't know where" debugging.

Troubleshooting Log: Three Issues That Cost Us an Extra Push

Even following the steps above, first-time setup may hit these—we've been there. Log signatures and fixes included.

Label Mismatch: Job Stuck Queued Forever

Symptom: GitHub Actions UI shows job Queued, Runners page shows node Online. Root cause: workflow runs-on labels don't exactly match registration—e.g. workflow has xcode-16.2 (dot), registration used xcode-16-2 (hyphen). GitHub label matching is exact string comparison; one character off and no routing.

Fix: On repo Runners page, click node name, view actual label list, copy-paste into YAML—don't type by hand.

Runner Offline After launchd Restart

Symptom: After node reboot or JexMac maintenance restart, Runner shows Offline; must SSH and run ./svc.sh start. Root cause: launchd plist UserName doesn't match SSH login user, or that user never completed first graphical/session login.

Fix: Confirm ./svc.sh install and ./svc.sh start run under the same user; after reboot check ./svc.sh status. If still failing, check stderr logs under /Library/Logs/GitHubActionsRunner/.

DerivedData Permission Conflict

Symptom: Second build fails with Unable to write to DerivedData or .o file permission denied. Root cause: first job created DerivedData as root or different user; subsequent job lacks write access.

Fix: Unify DerivedData path and add cleanup at workflow start: rm -rf ~/ci-runner/DerivedData && mkdir -p ~/ci-runner/DerivedData. Or clear only the project's hash subdirectory before each Archive to keep reusable compile cache.

No Always-On Mac? Rent a Dedicated Node by Build Cadence

By now you may see: self-hosted GitHub Actions Runner setup isn't hard—the real bottleneck is having a 24/7 online macOS physical machine with controllable Xcode version. Local MacBooks aren't ideal CI hosts—8 GB models max fans and swap during Archive; buying a company Mac mini means asset approval, colocation, and on-site cert rotation.

Our approach: rent a dedicated JexMac Mac mini M4 node as Runner host during sprint cycles (e.g. two weeks before release), renew weekly or release after launch stabilizes. Standard config: 16 GB unified memory, 256 GB NVMe, 1 Gbps dedicated bandwidth, from $21.5/day, SSH/VNC access in 1–5 minutes after payment, no contract lock-in. Five nodes—Singapore, Japan (Tokyo), Korea (Seoul), Hong Kong, US East—pick by team location to reduce git fetch and CocoaPods Specs sync latency.

vs GitHub hosted macOS Runner: peak queue 10–20 min vs self-hosted start in ~25 sec; macOS minutes billed at 10× vs predictable fixed daily rent. vs buying hardware: zero upfront; upgrade by changing labels or reinstalling Xcode, no procurement cycle.

Same node can double as remote dev desktop—browser VNC for UI debugging, Instruments for profiling; CI idle time isn't wasted. For small teams of 2–3, "one M4 physical machine = Runner + remote Mac dev environment" often beats separate hosted Runner minutes and local RAM upgrades.

Dedicated physical machine · 1–5 min delivery

Connect Your Runner to an M4 Node

All commands in this post verified on dedicated JexMac Mac mini M4 physical nodes. Provision → SSH access → register Runner per steps—Archive running within an hour. Daily rental from $21.5, release after sprint, no annual lock-in.

Standard Config
ChipApple M4 · 38 TOPS
CPU10-core (4P + 6E)
Memory16 GB unified memory
Network1 Gbps dedicated bandwidth
SLA99.9% uptime
Delivery1–5 min auto provisioning