Guide - CI/CD

Jenkins Mac Build Agent: Complete Setup Guide

Configure a Jenkins build agent on a dedicated Mac Mini M4. Automate Xcode builds, run iOS tests, and deploy to TestFlight with full control over your build infrastructure.

35 min read Updated January 2025

1. Why a Dedicated Mac for Jenkins?

Jenkins is the most widely used CI/CD platform, running over 50% of enterprise build pipelines. However, building iOS apps with Jenkins requires macOS, and macOS can only legally run on Apple hardware. A dedicated Mac Mini M4 gives you:

Consistent Build Environment

Same hardware, same OS, same tools every build. No flaky builds due to environment drift.

Full Root Access

Install any tools, configure system settings, manage keychains for code signing.

Apple Silicon Performance

M4 chip delivers 2-3x faster Xcode builds compared to Intel Mac Minis.

Persistent Caches

DerivedData, CocoaPods, and SPM caches survive between builds.

2. Prerequisites

3. Step 1: Install Java JDK on macOS

Jenkins agents require a Java Runtime Environment. We recommend using JDK 17 (LTS) or JDK 21 (LTS) installed via Homebrew for easy management.

# Install Homebrew if not already present
/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 Java JDK 17 (recommended for Jenkins)
brew install openjdk@17

# Create the symlink for system Java wrappers
sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk \
  /Library/Java/JavaVirtualMachines/openjdk-17.jdk

# Add to PATH
echo 'export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH"' >> ~/.zprofile
source ~/.zprofile

# Verify Java installation
java -version
# openjdk version "17.0.13" 2024-10-15
# OpenJDK Runtime Environment Homebrew (build 17.0.13+0)
# OpenJDK 64-Bit Server VM Homebrew (build 17.0.13+0, mixed mode, sharing)

4. Step 2: Configure Jenkins Agent

There are two primary methods to connect a Jenkins agent: JNLP (inbound connection from agent to controller) and SSH (outbound connection from controller to agent). We cover both.

Method A: SSH Agent (Recommended)

This is the most reliable approach. Jenkins connects to the Mac via SSH and manages the agent process remotely.

# On the Mac Mini: Create a dedicated Jenkins user
sudo dscl . -create /Users/jenkins
sudo dscl . -create /Users/jenkins UserShell /bin/zsh
sudo dscl . -create /Users/jenkins RealName "Jenkins Agent"
sudo dscl . -create /Users/jenkins UniqueID 550
sudo dscl . -create /Users/jenkins PrimaryGroupID 20
sudo dscl . -create /Users/jenkins NFSHomeDirectory /Users/jenkins
sudo mkdir -p /Users/jenkins
sudo chown jenkins:staff /Users/jenkins

# Set a password for the jenkins user
sudo dscl . -passwd /Users/jenkins "STRONG_PASSWORD_HERE"

# Create SSH directory and add the Jenkins controller's public key
sudo mkdir -p /Users/jenkins/.ssh
sudo sh -c 'echo "ssh-rsa YOUR_JENKINS_CONTROLLER_PUBLIC_KEY" > /Users/jenkins/.ssh/authorized_keys'
sudo chmod 700 /Users/jenkins/.ssh
sudo chmod 600 /Users/jenkins/.ssh/authorized_keys
sudo chown -R jenkins:staff /Users/jenkins/.ssh

# Create a workspace directory
sudo mkdir -p /Users/jenkins/workspace
sudo chown jenkins:staff /Users/jenkins/workspace

Now configure the agent in Jenkins:

  1. Go to Manage Jenkins > Nodes > New Node
  2. Set the name to mac-mini-m4
  3. Set Remote root directory to /Users/jenkins/workspace
  4. Labels: mac macos apple-silicon m4 ios
  5. Launch method: Launch agents via SSH
  6. Host: Your Mac Mini's IP address
  7. Credentials: Add the SSH private key matching the authorized_keys on the Mac

Method B: JNLP Agent (Inbound)

Use this method when the Jenkins controller cannot reach the Mac directly (e.g., the Mac is behind a firewall). The agent initiates the connection.

# First, create the node in Jenkins UI:
# Manage Jenkins > Nodes > New Node
# Launch method: "Launch agent by connecting it to the controller"
# Note the secret token from the node configuration page

# On the Mac Mini, download the agent JAR:
mkdir -p ~/jenkins-agent && cd ~/jenkins-agent
curl -sO https://your-jenkins-url/jnlpJars/agent.jar

# Test the agent connection
java -jar agent.jar \
  -url https://your-jenkins-url \
  -secret YOUR_AGENT_SECRET \
  -name "mac-mini-m4" \
  -workDir "/Users/jenkins/workspace"

Run JNLP Agent as a launchd Service

# Create the launchd plist file
cat > ~/Library/LaunchAgents/com.jenkins.agent.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.jenkins.agent</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/opt/openjdk@17/bin/java</string>
        <string>-jar</string>
        <string>/Users/jenkins/jenkins-agent/agent.jar</string>
        <string>-url</string>
        <string>https://your-jenkins-url</string>
        <string>-secret</string>
        <string>YOUR_AGENT_SECRET</string>
        <string>-name</string>
        <string>mac-mini-m4</string>
        <string>-workDir</string>
        <string>/Users/jenkins/workspace</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/jenkins/jenkins-agent/stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/jenkins/jenkins-agent/stderr.log</string>
</dict>
</plist>
EOF

# Load the service
launchctl load ~/Library/LaunchAgents/com.jenkins.agent.plist

# Verify it's running
launchctl list | grep jenkins

5. Step 3: Create iOS Build Pipeline

Create a Jenkinsfile in your repository root. This declarative pipeline builds, tests, and optionally archives your iOS app.

pipeline {
    agent { label 'mac && m4' }

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

    options {
        timeout(time: 30, unit: 'MINUTES')
        buildDiscarder(logRotator(numToKeepStr: '20'))
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Install Dependencies') {
            steps {
                sh '''
                    # Select Xcode version
                    sudo xcode-select -s /Applications/Xcode-16.2.app/Contents/Developer
                    xcodebuild -version

                    # Install CocoaPods dependencies
                    if [ -f "Podfile" ]; then
                        pod install --repo-update
                    fi
                '''
            }
        }

        stage('Build') {
            steps {
                sh '''
                    xcodebuild build \
                        -workspace "${WORKSPACE}" \
                        -scheme "${SCHEME}" \
                        -destination "${DESTINATION}" \
                        -derivedDataPath "${DERIVED_DATA}" \
                        CODE_SIGNING_ALLOWED=NO \
                        | xcbeautify
                '''
            }
        }

        stage('Test') {
            steps {
                sh '''
                    xcodebuild test \
                        -workspace "${WORKSPACE}" \
                        -scheme "${SCHEME}" \
                        -destination "${DESTINATION}" \
                        -derivedDataPath "${DERIVED_DATA}" \
                        -resultBundlePath "TestResults.xcresult" \
                        -parallel-testing-enabled YES \
                        | xcbeautify
                '''
            }
            post {
                always {
                    archiveArtifacts artifacts: 'TestResults.xcresult/**', allowEmptyArchive: true
                }
            }
        }

        stage('Archive') {
            when {
                branch 'main'
            }
            steps {
                sh '''
                    xcodebuild archive \
                        -workspace "${WORKSPACE}" \
                        -scheme "${SCHEME}" \
                        -archivePath "${DERIVED_DATA}/MyApp.xcarchive" \
                        -destination "generic/platform=iOS" \
                        | xcbeautify
                '''
            }
        }
    }

    post {
        success {
            echo 'Build and tests passed!'
        }
        failure {
            echo 'Build or tests failed.'
        }
        cleanup {
            sh 'xcrun simctl shutdown all 2>/dev/null || true'
        }
    }
}

6. Step 4: Integrate Fastlane for Deployment

Fastlane simplifies code signing and TestFlight deployment. Add a deploy stage to your Jenkinsfile:

# Install Fastlane on the Mac Mini (one-time setup)
brew install fastlane

# Or via Ruby:
gem install fastlane -NV

Add a deploy stage to your Jenkinsfile:

        stage('Deploy to TestFlight') {
            when {
                branch 'main'
            }
            environment {
                APP_STORE_CONNECT_API_KEY_ID     = credentials('app-store-key-id')
                APP_STORE_CONNECT_API_ISSUER_ID  = credentials('app-store-issuer-id')
                APP_STORE_CONNECT_API_KEY_CONTENT = credentials('app-store-key-content')
                MATCH_PASSWORD                   = credentials('match-password')
            }
            steps {
                sh '''
                    fastlane beta
                '''
            }
        }

And create a matching fastlane/Fastfile:

default_platform(:ios)

platform :ios do
  desc "Push a new beta build to TestFlight"
  lane :beta do
    setup_ci

    # Fetch code signing certificates via match
    match(type: "appstore", readonly: true)

    # Increment build number
    increment_build_number(
      build_number: ENV["BUILD_NUMBER"]
    )

    # Build the app
    build_app(
      workspace: "MyApp.xcworkspace",
      scheme: "MyApp",
      export_method: "app-store",
      derived_data_path: "DerivedData"
    )

    # Upload to TestFlight
    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      api_key: app_store_connect_api_key(
        key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
        issuer_id: ENV["APP_STORE_CONNECT_API_ISSUER_ID"],
        key_content: ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
      )
    )
  end
end

7. Security Best Practices

Use a dedicated Jenkins user

Never run the Jenkins agent as root. Create a dedicated user with minimal permissions.

Store secrets in Jenkins Credentials

Never hardcode certificates, API keys, or passwords in Jenkinsfiles. Use the Jenkins Credentials plugin.

Enable SSH key-based authentication only

Disable password authentication for SSH. Use Ed25519 or RSA 4096-bit keys.

Use the macOS Firewall

Restrict incoming connections to only the Jenkins controller IP. MyRemoteMac provides a managed firewall with API access.

Keep macOS and Xcode updated

Apply security patches promptly. Use softwareupdate -l to check for updates.

8. FAQ

Can I run the Jenkins controller on the same Mac?

Yes, but we recommend separating them. Run the controller on a Linux VM or Docker container, and use the Mac exclusively as a build agent. This avoids resource contention during heavy builds.

How many concurrent builds can a Mac Mini M4 handle?

A Mac Mini M4 with 16GB RAM can comfortably handle 2 concurrent Xcode builds. With 24GB, you can push to 3. The M4 Pro with 48GB handles 4+ concurrent builds easily. Configure the "Number of executors" in your Jenkins node settings accordingly.

Do I need a GUI session for iOS simulators?

No. iOS simulators work in headless mode over SSH. However, if you need a GUI (e.g., for UI testing screenshots), use VNC to log in to the Mac and ensure a GUI session is active.

How do I handle code signing on a headless server?

Use Fastlane Match to sync certificates from a Git repo or cloud storage. Alternatively, import certificates into the macOS keychain and unlock it in your pipeline using security unlock-keychain.

Related Guides

Ready to Supercharge Your Jenkins Builds?

Get a dedicated Mac Mini M4 for your Jenkins CI/CD pipeline. Starting at $75/month with a 7-day free trial.