Guide - CI/CD

How to Set Up a GitHub Actions Self-Hosted Runner on Mac Mini M4

Step-by-step guide to setting up a GitHub Actions self-hosted runner on a dedicated Mac Mini M4. Faster iOS builds, Apple Silicon native, and up to 10x cheaper than GitHub-hosted macOS runners.

30 min read Updated January 2025

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.

Related Guides

Ready to Speed Up Your iOS Builds?

Deploy a dedicated Mac Mini M4 as your GitHub Actions runner. Starting at $75/month with a 7-day free trial.