Guide - CI/CD

GitLab CI/CD Runner on Mac Mini M4: Complete Guide

Install and configure a GitLab CI/CD runner on a dedicated Mac Mini M4. Build iOS apps natively on Apple Silicon, run tests on real simulators, and deploy to TestFlight -- all from your GitLab pipeline.

30 min read Updated January 2025

1. Why Self-Hosted GitLab Runners on Mac?

GitLab offers shared runners on Linux, but building iOS apps requires macOS running on Apple hardware. GitLab's own macOS shared runners are limited and expensive. A self-hosted Mac Mini M4 runner gives you:

Unlimited CI/CD Minutes

GitLab's free tier includes 400 CI/CD minutes on shared runners. On self-hosted, there is no limit.

Native Apple Silicon

Build on the same M4 chip your users' devices run. No Rosetta translation overhead.

Full Environment Control

Install any Xcode version, simulators, tools, and dependencies you need.

Persistent Caches

DerivedData, SPM packages, and CocoaPods caches survive between pipeline runs.

2. Install GitLab Runner

SSH into your Mac Mini M4 and install the GitLab Runner using Homebrew:

# Connect to your Mac Mini M4
ssh admin@your-server-ip

# Install Homebrew (if not already installed)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"

# Install GitLab Runner
brew install gitlab-runner

# Verify installation
gitlab-runner --version
# Version:      17.7.0
# Git revision:  ...
# Git branch:    17-7-stable
# GO version:    go1.22.10
# Built:         ...
# OS/Arch:       darwin/arm64

Install as a macOS Service

# Install the runner as a launchd service
# This ensures it starts automatically on boot
brew services start gitlab-runner

# Verify the service is running
brew services list | grep gitlab-runner
# gitlab-runner started admin ~/Library/LaunchAgents/homebrew.mxcl.gitlab-runner.plist

# Check runner status
gitlab-runner status
# gitlab-runner: Service is running

3. Register the Runner

Go to your GitLab project (or group) and navigate to Settings > CI/CD > Runners > New project runner. Copy the registration token.

Register with the New Runner Registration Flow (GitLab 16+)

# Register the runner using the authentication token from GitLab UI
# (GitLab 16+ uses authentication tokens instead of registration tokens)
gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com/" \
  --token "YOUR_RUNNER_AUTHENTICATION_TOKEN" \
  --executor "shell" \
  --description "mac-mini-m4-runner" \
  --tag-list "macos,apple-silicon,m4,ios,xcode"

Register with Legacy Registration Token (GitLab 15 and earlier)

# For older GitLab instances using registration tokens
gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com/" \
  --registration-token "YOUR_REGISTRATION_TOKEN" \
  --executor "shell" \
  --description "mac-mini-m4-runner" \
  --tag-list "macos,apple-silicon,m4,ios,xcode" \
  --run-untagged="false"

Verify Runner Configuration

# View the runner config file
cat ~/.gitlab-runner/config.toml

# Expected output:
# concurrent = 2
# check_interval = 0
#
# [session_server]
#   session_timeout = 1800
#
# [[runners]]
#   name = "mac-mini-m4-runner"
#   url = "https://gitlab.com/"
#   token = "..."
#   executor = "shell"
#   [runners.cache]
#     MaxUploadedArchiveSize = 0

# Adjust concurrency based on your hardware:
# Mac Mini M4 (16GB): concurrent = 2
# Mac Mini M4 Pro (24GB): concurrent = 3
# Mac Mini M4 Pro (48GB): concurrent = 4

Edit ~/.gitlab-runner/config.toml to adjust the concurrency:

# Edit the config
nano ~/.gitlab-runner/config.toml

# Set concurrent to match your hardware capacity
concurrent = 2

# Restart the runner to apply changes
gitlab-runner restart

The runner should now appear as Online in your GitLab project under Settings > CI/CD > Runners.

4. Create .gitlab-ci.yml for iOS

Create a .gitlab-ci.yml file in your repository root. This complete pipeline builds, tests, and deploys your iOS app:

# .gitlab-ci.yml

stages:
  - setup
  - build
  - test
  - deploy

variables:
  SCHEME: "MyApp"
  WORKSPACE: "MyApp.xcworkspace"
  DESTINATION: "platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2"
  DERIVED_DATA: "${CI_PROJECT_DIR}/DerivedData"

# Only run on our Mac runner
default:
  tags:
    - macos
    - m4

# ---- SETUP ----

setup:
  stage: setup
  script:
    - sudo xcode-select -s /Applications/Xcode-16.2.app/Contents/Developer
    - xcodebuild -version
    - swift --version
    # Install CocoaPods if using Podfile
    - |
      if [ -f "Podfile" ]; then
        pod install --repo-update
      fi
  cache:
    key: pods-${CI_COMMIT_REF_SLUG}
    paths:
      - Pods/
      - .spm-cache/

# ---- BUILD ----

build:
  stage: build
  needs: ["setup"]
  script:
    - |
      xcodebuild build \
        -workspace "${WORKSPACE}" \
        -scheme "${SCHEME}" \
        -destination "${DESTINATION}" \
        -derivedDataPath "${DERIVED_DATA}" \
        -clonedSourcePackagesDirPath ".spm-cache" \
        CODE_SIGNING_ALLOWED=NO \
        | xcbeautify
  cache:
    key: derived-data-${CI_COMMIT_REF_SLUG}
    paths:
      - DerivedData/
      - .spm-cache/
  artifacts:
    paths:
      - DerivedData/
    expire_in: 1 hour

# ---- TEST ----

unit_tests:
  stage: test
  needs: ["build"]
  script:
    - |
      xcodebuild test \
        -workspace "${WORKSPACE}" \
        -scheme "${SCHEME}" \
        -destination "${DESTINATION}" \
        -derivedDataPath "${DERIVED_DATA}" \
        -resultBundlePath "TestResults.xcresult" \
        -parallel-testing-enabled YES \
        | xcbeautify
  artifacts:
    when: always
    paths:
      - TestResults.xcresult/
    reports:
      junit: TestResults.xcresult/report.junit
    expire_in: 7 days
  after_script:
    - xcrun simctl shutdown all 2>/dev/null || true

# ---- DEPLOY ----

deploy_testflight:
  stage: deploy
  needs: ["unit_tests"]
  only:
    - main
  script:
    - |
      # Install or update Fastlane
      which fastlane || brew install fastlane

      # Run Fastlane beta lane
      fastlane beta
  environment:
    name: testflight
  variables:
    MATCH_PASSWORD: ${MATCH_PASSWORD}
    APP_STORE_CONNECT_API_KEY_ID: ${APP_STORE_KEY_ID}
    APP_STORE_CONNECT_API_ISSUER_ID: ${APP_STORE_ISSUER_ID}
    APP_STORE_CONNECT_API_KEY_CONTENT: ${APP_STORE_KEY_CONTENT}

Add CI/CD Variables in GitLab

Navigate to Settings > CI/CD > Variables in your GitLab project and add these variables as "Masked" and "Protected":

  • MATCH_PASSWORD - Password for Fastlane Match encryption
  • APP_STORE_KEY_ID - App Store Connect API Key ID
  • APP_STORE_ISSUER_ID - App Store Connect Issuer ID
  • APP_STORE_KEY_CONTENT - The .p8 key file contents

5. Optimize Performance

GitLab Cache Configuration

GitLab Runner supports local caching for shell executors. Since your runner is persistent, local caches are extremely efficient:

# In .gitlab-ci.yml, configure cache per branch:
cache:
  key: "${CI_COMMIT_REF_SLUG}"
  paths:
    - DerivedData/
    - .spm-cache/
    - Pods/
  policy: pull-push

# For test jobs that don't modify cache, use pull-only:
unit_tests:
  cache:
    key: "${CI_COMMIT_REF_SLUG}"
    paths:
      - DerivedData/
    policy: pull

Use Artifacts for Inter-Stage Data

# Pass build artifacts between stages efficiently
build:
  artifacts:
    paths:
      - DerivedData/Build/Products/
    expire_in: 2 hours

# The test stage receives the built products without rebuilding
test:
  needs: ["build"]  # only download artifacts from the build job
  script:
    - xcodebuild test-without-building \
        -scheme "${SCHEME}" \
        -destination "${DESTINATION}" \
        -derivedDataPath "${DERIVED_DATA}"

Parallel Test Execution

# Split tests across parallel jobs using GitLab's parallel keyword
unit_tests:
  stage: test
  parallel: 2
  script:
    - |
      # Use test plan partitioning or custom splitting
      xcodebuild test \
        -workspace "${WORKSPACE}" \
        -scheme "${SCHEME}" \
        -destination "${DESTINATION}" \
        -derivedDataPath "${DERIVED_DATA}" \
        -parallel-testing-enabled YES \
        -maximum-parallel-testing-workers 4

Scheduled Cache Cleanup

# On the Mac Mini, set up a weekly cleanup cron job
crontab -e

# Add these lines:
# Clean DerivedData older than 7 days every Sunday at 3 AM
0 3 * * 0 find ~/builds/*/DerivedData -maxdepth 0 -mtime +7 -exec rm -rf {} + 2>/dev/null

# Clean old GitLab Runner builds older than 14 days
0 4 * * 0 find ~/builds -maxdepth 2 -mtime +14 -type d -exec rm -rf {} + 2>/dev/null

# Clean Homebrew cache monthly
0 5 1 * * /opt/homebrew/bin/brew cleanup --prune=30 2>/dev/null

6. Troubleshooting

Runner shows "offline" in GitLab

Check the runner service status and logs:

# Check service status
brew services list | grep gitlab-runner

# View logs
cat /usr/local/var/log/gitlab-runner.log

# Restart the service
brew services restart gitlab-runner

# Verify connectivity to GitLab
gitlab-runner verify

Permission denied errors during builds

The runner may need access to the Xcode developer directory:

# Ensure the runner user has Xcode access
sudo xcode-select -s /Applications/Xcode-16.2.app/Contents/Developer
sudo xcodebuild -license accept

# If using simulators, ensure the user can access them
xcrun simctl list devices

Cache not being restored

Ensure cache keys are consistent and paths exist:

# Check cache directory permissions
ls -la ~/builds/

# The shell executor stores caches locally by default
# Verify the cache directory in config.toml:
cat ~/.gitlab-runner/config.toml

# Ensure [runners.cache] section has the right settings
# For local caching (most efficient for persistent runners):
# [runners.cache]
#   Type = ""  # empty = local cache

Xcode build hangs or times out

This is often caused by keychain access prompts or simulator issues:

# Unlock the keychain before builds
security unlock-keychain -p "YOUR_PASSWORD" ~/Library/Keychains/login.keychain-db

# Kill stuck simulators
xcrun simctl shutdown all
pkill -f "Simulator.app" 2>/dev/null || true

# Set a build timeout in .gitlab-ci.yml
build:
  timeout: 30 minutes

7. FAQ

Can I use a Docker executor on macOS?

Docker on macOS runs Linux containers in a VM, which cannot access macOS APIs, Xcode, or iOS simulators. For iOS builds, you must use the shell executor. Docker is fine for server-side Swift or other Linux-based tasks running alongside your Mac runner.

How do I register the runner for a GitLab group?

Go to your GitLab group's Settings > CI/CD > Runners > New group runner. Use the group runner token instead of a project token. This makes the runner available to all projects within the group.

Should I use the shell executor or SSH executor?

Use the shell executor. It runs commands directly on the Mac, which gives full access to Xcode, simulators, and the keychain. The SSH executor is for remote machines, which is unnecessary when the runner is already on the Mac.

Can I run both GitLab and GitHub Actions runners on the same Mac?

Yes. Both runners are lightweight and can coexist on the same Mac Mini M4. Just make sure to account for the combined resource usage when setting concurrency levels for each runner.

How do I update the GitLab Runner?

# Update via Homebrew
brew upgrade gitlab-runner

# Restart the service
brew services restart gitlab-runner

# Verify the new version
gitlab-runner --version

Related Guides

Ready to Power Your GitLab Pipelines?

Get a dedicated Mac Mini M4 for your GitLab CI/CD runner. Unlimited builds starting at $75/month with a 7-day free trial.