1. Why Use a Self-Hosted Mac Runner?
GitHub-hosted macOS runners are convenient but expensive. At $0.08 per minute, a team running 75 hours of builds per month pays around $360. A dedicated Mac Mini M4 from MyRemoteMac costs $75/month with unlimited build minutes, giving you the same (or better) hardware for a fraction of the cost.
| Feature | GitHub-Hosted Runner | MyRemoteMac Self-Hosted |
|---|---|---|
| Cost | $0.08/min (~$350/mo for 75h) | $75/mo (unlimited minutes) |
| Architecture | Intel x86 (some M1) | Apple M4 (latest) |
| Build Speed | ~12 min (medium project) | ~4 min (same project) |
| Persistent Cache | No (ephemeral) | Yes (persistent disk) |
| Custom Software | Limited | Full root access |
| Concurrent Jobs | 5 (free) / 20 (paid) | Unlimited (your hardware) |
Key Benefit: Because the runner is persistent, DerivedData, SPM caches, and CocoaPods are preserved between builds. This alone can cut build times by 50-70% compared to ephemeral GitHub-hosted runners that start from scratch every time.
2. Prerequisites
Before you begin, make sure you have the following:
- A Mac Mini M4 server from MyRemoteMac (from $75/mo)
- A GitHub account with admin access to your repository or organization
- SSH access to your Mac Mini (provided with your MyRemoteMac subscription)
- An Apple Developer account (for code signing and provisioning profiles)
- Basic familiarity with YAML and terminal commands
3. Step 1: SSH into Your Mac Mini and Install Xcode
First, connect to your Mac Mini M4 via SSH. You will have received your credentials when you set up your MyRemoteMac server.
Connect via SSH
# Connect to your Mac Mini M4
ssh admin@your-server-ip
# Verify you're on Apple Silicon
uname -m
# Expected output: arm64
# Check macOS version
sw_vers
# ProductName: macOS
# ProductVersion: 15.2
# BuildVersion: 24C101
Install Xcode Command Line Tools
# Install Command Line Tools
xcode-select --install
# Accept the license agreement
sudo xcodebuild -license accept
# Verify installation
xcode-select -p
# /Library/Developer/CommandLineTools
Install Xcode (Full Version)
For iOS builds, you need the full Xcode application. The fastest way to install it on a headless server is using xcodes:
# Install Homebrew (if not already installed)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Add Homebrew to PATH
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
# Install xcodes CLI tool
brew install xcodes
# List available Xcode versions
xcodes list
# Install the latest stable Xcode
xcodes install 16.2
# Set it as the active Xcode
sudo xcode-select -s /Applications/Xcode-16.2.app/Contents/Developer
# Verify
xcodebuild -version
# Xcode 16.2
# Build version 16C5032a
Install iOS Simulators
# Install the iOS 18 simulator runtime
xcodebuild -downloadPlatform iOS
# Verify simulator availability
xcrun simctl list runtimes
# == Runtimes ==
# iOS 18.2 (18.2 - 22C150) - com.apple.CoreSimulator.SimRuntime.iOS-18-2
4. Step 2: Install the GitHub Actions Runner
Now let's download and configure the GitHub Actions runner agent. Navigate to your repository on GitHub, go to Settings > Actions > Runners > New self-hosted runner, and select macOS + ARM64.
Download and Configure
# Create a directory for the runner
mkdir -p ~/actions-runner && cd ~/actions-runner
# Download the latest runner package (ARM64)
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
# Extract the package
tar xzf actions-runner-osx-arm64-2.321.0.tar.gz
# Configure the runner
# Replace YOUR_TOKEN with the token from GitHub Settings
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \
--token YOUR_TOKEN \
--name "mac-mini-m4-runner" \
--labels "self-hosted,macOS,ARM64,M4" \
--work "_work"
# Test the runner interactively first
./run.sh
Install as a Persistent launchd Service
Running the agent interactively is fine for testing, but for production you need it to start automatically on boot and restart if it crashes. GitHub provides a built-in service installation script for macOS:
# Install as a launchd service
cd ~/actions-runner
sudo ./svc.sh install
# Start the service
sudo ./svc.sh start
# Check the service status
sudo ./svc.sh status
# Expected: "active (running)"
# View the launchd plist (for reference)
cat /Library/LaunchDaemons/actions.runner.*.plist
The service will now automatically start on boot and restart if the process dies. You can verify the runner appears as "Idle" in your GitHub repository's Settings > Actions > Runners page.
Configure Runner for Multiple Repositories (Organization-Level)
# For an organization-level runner, use the organization URL:
./config.sh --url https://github.com/YOUR_ORG \
--token YOUR_ORG_TOKEN \
--name "mac-mini-m4-org-runner" \
--labels "self-hosted,macOS,ARM64,M4" \
--runnergroup "Default" \
--work "_work"
# This allows ALL repositories in your organization to use this runner
5. Step 3: Create Your iOS Build Workflow
Create a workflow file in your repository at .github/workflows/ios-build.yml. This workflow will run on your self-hosted Mac Mini M4 runner.
name: iOS Build & Test
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: [self-hosted, macOS, ARM64, M4]
env:
SCHEME: "MyApp"
DESTINATION: "platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Select Xcode version
run: |
sudo xcode-select -s /Applications/Xcode-16.2.app/Contents/Developer
xcodebuild -version
- name: Resolve Swift Package Dependencies
run: |
xcodebuild -resolvePackageDependencies \
-scheme "$SCHEME" \
-clonedSourcePackagesDirPath .spm-cache
- name: Build the app
run: |
xcodebuild build \
-scheme "$SCHEME" \
-destination "$DESTINATION" \
-clonedSourcePackagesDirPath .spm-cache \
-derivedDataPath DerivedData \
| xcbeautify
- name: Run unit tests
run: |
xcodebuild test \
-scheme "$SCHEME" \
-destination "$DESTINATION" \
-clonedSourcePackagesDirPath .spm-cache \
-derivedDataPath DerivedData \
-resultBundlePath TestResults.xcresult \
| xcbeautify
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: TestResults.xcresult
Add Code Signing for Release Builds
For TestFlight or App Store deployments, add code signing steps. Store your certificates and provisioning profiles as GitHub Secrets:
deploy:
needs: build
runs-on: [self-hosted, macOS, ARM64, M4]
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install certificate and provisioning profile
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create a temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Install provisioning profile
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
- name: Build for distribution
run: |
xcodebuild archive \
-scheme "MyApp" \
-archivePath DerivedData/MyApp.xcarchive \
-destination "generic/platform=iOS" \
CODE_SIGN_STYLE=Manual
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath DerivedData/MyApp.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath DerivedData/Export
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
run: |
xcrun altool --upload-app \
-f DerivedData/Export/MyApp.ipa \
-t ios \
--apiKey $APP_STORE_CONNECT_API_KEY
6. Step 4: Optimize Performance
One of the biggest advantages of a self-hosted runner is persistent caching. Here are key optimizations to get the most out of your Mac Mini M4.
Enable DerivedData Caching
Since the runner is persistent, DerivedData is preserved between builds. Use a consistent DerivedData path:
# In your workflow, always use:
-derivedDataPath DerivedData
# On the runner, periodically clean old DerivedData to save space:
# Add a cron job to clean builds older than 7 days
echo "0 3 * * 0 find ~/actions-runner/_work/*/DerivedData -maxdepth 0 -mtime +7 -exec rm -rf {} +" \
| crontab -
Cache SPM Packages
# Use clonedSourcePackagesDirPath to keep SPM packages on disk
xcodebuild build \
-scheme "MyApp" \
-clonedSourcePackagesDirPath ~/spm-cache \
-derivedDataPath DerivedData
# This avoids re-downloading packages on every build
Parallel Test Execution
# Run tests in parallel across multiple simulators
xcodebuild test \
-scheme "MyApp" \
-destination "platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2" \
-destination "platform=iOS Simulator,name=iPhone 15,OS=17.5" \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4 \
-derivedDataPath DerivedData \
| xcbeautify
Install xcbeautify for Better Logs
# xcbeautify formats Xcode output for CI environments
brew install xcbeautify
# Use it by piping xcodebuild output:
xcodebuild build -scheme "MyApp" | xcbeautify
7. Troubleshooting Common Issues
Runner shows as "Offline" in GitHub
This usually means the launchd service is not running. Check the service status and logs:
# Check service status
sudo ./svc.sh status
# View logs
cat ~/actions-runner/_diag/Runner_*.log | tail -50
# Restart the service
sudo ./svc.sh stop
sudo ./svc.sh start
Code signing fails with "No signing certificate"
The launchd service runs under a different user context. Make sure the keychain is accessible:
# Ensure the login keychain is unlocked for the runner user
security unlock-keychain -p "YOUR_PASSWORD" ~/Library/Keychains/login.keychain-db
# Or use a dedicated keychain in your workflow (recommended)
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
Simulator fails to boot
Simulators sometimes get stuck. Reset them between builds:
# Shutdown all running simulators
xcrun simctl shutdown all
# Erase all simulator data (nuclear option)
xcrun simctl erase all
# Boot a specific simulator
xcrun simctl boot "iPhone 16 Pro"
Disk space running low
Xcode builds generate a lot of data. Set up automatic cleanup:
# Clean old DerivedData
rm -rf ~/Library/Developer/Xcode/DerivedData/*
# Remove old simulator runtimes
xcrun simctl runtime delete all
# Clean Homebrew cache
brew cleanup --prune=7
# Remove old Xcode archives
rm -rf ~/Library/Developer/Xcode/Archives/*
8. Cost Analysis
Here is a detailed cost comparison for different team sizes and build volumes:
| Team Size | Builds/Month | GitHub-Hosted Cost | MyRemoteMac Cost | Monthly Savings |
|---|---|---|---|---|
| Solo Dev | 100 builds (10 min avg) | $80/mo | $75/mo | $5/mo |
| Small Team (5) | 500 builds (10 min avg) | $400/mo | $75/mo | $325/mo |
| Medium Team (15) | 1500 builds (10 min avg) | $1,200/mo | $179/mo (M4 Pro) | $1,021/mo |
| Enterprise (50+) | 5000+ builds | $4,000+/mo | $358/mo (2x M4 Pro) | $3,642+/mo |
Bottom Line: For teams running more than ~100 builds per month, a self-hosted Mac Mini M4 pays for itself immediately. And because builds are faster on persistent hardware with warm caches, your team saves developer time too.