What you start with

When you rent a dedicated cloud Mac mini M4 from KVMFLUX, you get root-equivalent admin on real Apple hardware: 16GB unified memory, 256GB SSD, macOS preinstalled, reachable over SSH and VNC. Nothing is shared and nothing is virtualized, so xcodebuild sees the full M4 and the Secure Enclave behaves like it does on your desk.

Pick the region closest to your artifact storage, not to your team. A runner in Singapore, Japan, South Korea, Hong Kong, US East and US West talks to your Git host and CDN far more often than any human talks to it.

Step 1 — Lock down SSH before anything else

The box arrives with password login enabled so you can get in. Your first session should end that. Push a key, then turn passwords off.

local shell
ssh-keygen -t ed25519 -f ~/.ssh/kvmflux_ci -C "ci-runner"
ssh-copy-id -i ~/.ssh/kvmflux_ci.pub admin@<YOUR_MAC_HOST>
on the mac
sudo tee -a /etc/ssh/sshd_config <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
EOF
sudo launchctl kickstart -k system/com.openssh.sshd

Two more settings matter for a CI node. Stop the machine from sleeping, and keep it from pausing background daemons when no display is attached.

on the mac
sudo pmset -a sleep 0 displaysleep 0 disksleep 0
sudo systemsetup -setrestartfreeze on

Keep VNC as your break-glass path. Some Xcode dialogs — license prompts, simulator first-run permissions — only surface in the GUI session, and you want a way in when SSH config goes sideways.

Step 2 — Install the Xcode toolchain

Skip the App Store. xcodes installs specific Xcode versions non-interactively, which is exactly what a headless node needs. Install Homebrew first, then the toolchain.

on the mac
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install xcodesorg/made/xcodes aria2
xcodes install 16.2 --experimental-unxip
sudo xcodes select 16.2
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch

Verify the destination you will actually build for. If your pipeline runs simulator tests, download the runtime now rather than during the first job.

on the mac
xcodebuild -downloadPlatform iOS
xcrun simctl list runtimes
xcodebuild -version

Add the usual suspects for a mobile pipeline: fastlane for lanes and signing, git-lfs if your repo carries binary assets.

on the mac
brew install fastlane git-lfs jq
git lfs install --system

Step 3 — Register the machine as a runner

The pattern is the same on GitHub Actions, GitLab and Buildkite: download an agent, hand it a scoped token, run it as a service. Here is the GitHub Actions version for Apple silicon.

on the mac
mkdir ~/actions-runner && cd ~/actions-runner
curl -o runner.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-osx-arm64-2.321.0.tar.gz
tar xzf runner.tar.gz
./config.sh --url https://github.com/your-org/your-app \
  --token <REGISTRATION_TOKEN> \
  --labels macos,arm64,m4 --unattended
./svc.sh install && ./svc.sh start

Installing with svc.sh wires the runner into launchd, so it survives reboots without a logged-in desktop session. Target it from a workflow by label:

.github/workflows/ios.yml
jobs:
  build:
    runs-on: [self-hosted, macos, m4]
    steps:
      - uses: actions/checkout@v4
      - run: xcodebuild -scheme App -destination \
          'platform=iOS Simulator,name=iPhone 16' test

Registration tokens expire in an hour and are single-purpose — that part is fine. The runner's long-lived credentials live in .credentials inside the runner directory, so keep that user account boring: no personal SSH keys, no browser sessions.

Step 4 — Give CI a keychain it can unlock

Signing is where most self-hosted Mac setups stall. The fix is a dedicated keychain that your lane creates, unlocks and throws away, so the login keychain never prompts and never leaks between jobs.

signing lane
KEYCHAIN=ci.keychain-db
security create-keychain -p "$KEYCHAIN_PASS" $KEYCHAIN
security set-keychain-settings -lut 3600 $KEYCHAIN
security unlock-keychain -p "$KEYCHAIN_PASS" $KEYCHAIN
security import dist.p12 -k $KEYCHAIN -P "$P12_PASS" \
  -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: \
  -s -k "$KEYCHAIN_PASS" $KEYCHAIN
security list-keychains -d user -s $KEYCHAIN login.keychain-db

The set-key-partition-list line is the one everyone forgets — without it, codesign hangs waiting for a GUI password prompt that no one will ever see. If you use fastlane, match with a private certificates repo automates this whole block.

Caching: keep the wins on disk

A dedicated box beats ephemeral runners precisely because state persists. Lean into that instead of re-downloading the world per job:

  • DerivedData — point it at a stable path with -derivedDataPath ~/ci-cache/dd and incremental builds routinely drop from minutes to seconds.
  • Swift packages — set -clonedSourcePackagesDirPath ~/ci-cache/spm so dependency resolution reuses checkouts across jobs.
  • CocoaPods and Homebrew — both cache under the runner user's home by default; just do not wipe the workspace between builds.

Budget the disk honestly. On the 256GB SSD, plan roughly 40GB for Xcode plus runtimes, and expect caches to grow toward 60–80GB on an active app. Prune on a schedule instead of on a crisis:

weekly cron
find ~/ci-cache/dd -maxdepth 1 -mtime +14 -exec rm -rf {} +
xcrun simctl delete unavailable
df -h /

If your project ships large assets or you keep multiple Xcode versions side by side, the Extra SSD +1TB add-on at $11.7/mo is cheaper than debugging a full disk at release time.

Concurrency: how many jobs per M4?

One M4 with 16GB handles one heavy job well: a full clean build plus simulator test suite will happily use every core. Running two simulator-based jobs in parallel works for small apps but degrades both once memory pressure kicks in.

Our working rules after a few months of pipeline duty:

  • One runner process, one job at a time, is the reliable default for build-and-test workloads.
  • Split lint, unit tests and UI tests into separate jobs only if you add a second machine — queueing them on one box just serializes with extra overhead.
  • Nightly jobs are free capacity: schedule dependency audits and screenshot runs outside working hours in the region's timezone.

When queue time becomes your bottleneck, adding a second rented node scales linearly — register it with the same labels and the scheduler load-balances for free. The math on daily versus monthly nodes is its own topic; we ran the numbers in our rental period cost breakdown, and the surrounding workflows live in the use cases overview.

The whole setup, compressed

  1. Key-only SSH, sleep disabled, VNC kept as fallback.
  2. Xcode via xcodes, license accepted, runtimes pre-downloaded.
  3. Runner registered as a launchd service with meaningful labels.
  4. Dedicated CI keychain with partition list set.
  5. Persistent caches with a scheduled prune.

Total hands-on time is under an hour, and the result is a build node your team stops thinking about — which is the point.

Try it on real hardware

Every command above was written and tested on the exact machine we rent out. Provision one, follow along, and cancel if it does not fit — no hardware invoice either way.

Mac Mini M4 · 16GB / 256GB
Daily$19.3 /day
Weekly$52.2 /wk
Monthly$96.7 /mo
Quarterly$263 /qtr

Dedicated hardware, USD billing, cancel any time.