How to add a Mac as a Jenkins agent for iOS builds
To add a Mac to Jenkins, create a permanent node that connects to the controller. On the Mac, install Java 21 and run agent.jar with the -webSocket option. Wrap that command in a LaunchAgent and turn on automatic login, so the agent reconnects after a reboot. Then label the node macos and send your iOS stages to it.
What you need first
- A Jenkins controller the Mac can reach over HTTPS.
- An Apple silicon Mac with admin access, Homebrew and Xcode.
- The right Java. Jenkins' Java support policy, read in October 2026, says LTS 2.555.1 and newer need Java 21 or 25. That rule covers agents too, not only the controller.
- A desktop session on the Mac for the last steps, through screen sharing.
1. Install Java on the Mac
brew install openjdk@21 /opt/homebrew/opt/openjdk@21/bin/java -version
Homebrew installs this JDK keg-only, so it is not on your PATH. Use the full path above everywhere. That also keeps the agent on Java 21 when a newer JDK arrives later.
2. Create the node on the controller
- Open Manage Jenkins, then Nodes, then New Node. Pick Permanent Agent.
- Number of executors: 1. Jenkins' own docs call one executor per node the safest setting. Xcode builds use every core already.
- Remote root directory:
/Users/YOUR-USER/jenkins. - Labels:
macos xcode. - Usage: only build jobs with label expressions matching this node. That keeps Linux jobs off your Mac.
- Launch method: Launch agent by connecting it to the controller.
Save. The node's page now shows the run command, the agent name and a long hex secret. The secret is tied to the agent name. If it leaks, Jenkins says not to reuse that name.
3. Download agent.jar and test by hand
Jenkins serves the right agent.jar for your controller at /jnlpJars/agent.jar. Store the secret in a file and pass it with @, so it never shows up in a process list.
mkdir -p ~/jenkins && cd ~/jenkins curl -sO https://jenkins.example.com/jnlpJars/agent.jar echo 'PASTE-THE-SECRET' > secret-file chmod 600 secret-file /opt/homebrew/opt/openjdk@21/bin/java -jar agent.jar \ -url https://jenkins.example.com/ \ -name mac-mini-1 \ -secret @secret-file \ -workDir "$HOME/jenkins" \ -webSocket
The node page should switch to connected. Press Control C to stop it. With -webSocket, the agent makes one HTTPS connection. Without it, the agent also needs the controller's separate inbound TCP port.
4. Run the agent from a LaunchAgent
launchd starts the agent at login and restarts it if it exits. Run this from Terminal on the Mac's desktop. The heredoc fills in your home folder path.
mkdir -p ~/Library/LaunchAgents
cat > ~/Library/LaunchAgents/local.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>local.jenkins-agent</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/openjdk@21/bin/java</string>
<string>-jar</string><string>$HOME/jenkins/agent.jar</string>
<string>-url</string><string>https://jenkins.example.com/</string>
<string>-name</string><string>mac-mini-1</string>
<string>-secret</string><string>@$HOME/jenkins/secret-file</string>
<string>-workDir</string><string>$HOME/jenkins</string>
<string>-webSocket</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>LANG</key><string>en_US.UTF-8</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>30</integer>
<key>StandardOutPath</key><string>$HOME/jenkins/agent.log</string>
<key>StandardErrorPath</key><string>$HOME/jenkins/agent.log</string>
</dict>
</plist>
EOF
plutil -lint ~/Library/LaunchAgents/local.jenkins-agent.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.jenkins-agent.plist
launchctl print gui/$(id -u)/local.jenkins-agent | head -20We checked this plist with plutil -lint. Why a LaunchAgent and not a LaunchDaemon? A daemon runs outside any login session. Simulators and the login keychain live inside one. GitLab and Buildkite give the same reason for their Mac agents.
Keep the plist in ~/Library/LaunchAgents. On macOS 27 we saw a plist in /Library/LaunchAgents break auto-login on every boot. The details are in auto-login on a headless Mac.
5. Make it survive a reboot
Turn on automatic login for the agent's account in System Settings, under Users and Groups. Stop the Mac from sleeping. Then reboot and watch the node page.
sudo pmset -a sleep 0 sudo shutdown -r now # then, after it is back: tail -n 20 ~/jenkins/agent.log
6. A Jenkinsfile with an iOS stage
pipeline {
agent { label 'macos' }
options { timeout(time: 30, unit: 'MINUTES') }
stages {
stage('Test') {
steps {
sh 'xcodebuild -version'
sh 'rm -rf build/TestResults.xcresult'
sh "xcodebuild test -project MyApp.xcodeproj -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' -resultBundlePath build/TestResults.xcresult"
}
}
}
post {
always {
sh 'ditto -c -k --keepParent build/TestResults.xcresult build/TestResults.xcresult.zip || true'
archiveArtifacts artifacts: 'build/TestResults.xcresult.zip', fingerprint: true
}
}
}A result bundle is a folder, so we zip it with ditto before archiving. Open the zip on any Mac and double click the bundle to see it in Xcode.
Two Xcode versions on one agent? Pick one per pipeline with DEVELOPER_DIR. Apple's xcode-select man page says it overrides the system-wide choice without changing it. Set it in an environment block, so other jobs on the Mac are not affected:
environment {
DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'
}Put that block inside pipeline, next to agent. Changing the global setting with sudo xcode-select -s works too, but it changes every job on the machine at once.
Common errors and fixes
- The agent connects, then is gone after a reboot. Nobody logged in, so the LaunchAgent never started. Check automatic login.
launchctl bootstrapfails with a domain error over SSH. Run it from Terminal on the desktop. GitLab and Buildkite document the same error for their LaunchAgents.- The agent refuses to start and mentions Java. Jenkins checks the Java version at launch. Match the version in the support policy.
xcodebuild: error: Existing file at -resultBundlePath. The workspace persists between builds. Delete the old bundle first, as in the Jenkinsfile.
Why a dedicated Mac helps
A permanent agent keeps its workspace and Xcode's DerivedData between builds. Incremental builds are the payoff. In our benchmark we used the Wikipedia iOS app on Xcode 26.6, median of 3 runs. A small change rebuilt in 27 seconds on a warm M6. A fresh GitHub-hosted macos-26 runner took 269 seconds for the same job.
When you do not need this
If you do not run Jenkins already, do not start for one iOS app. A hosted service with Mac runners is less to maintain. At GitHub's rate of $0.062 per macOS minute (checked September 2026), a flat $139 Mac pays off after about 2,242 minutes a month. Below that, metered is cheaper. MacRun is also the wrong fit if your controller only accepts agents from a fixed IP. We have no static IP.
Our other CI systems page covers Jenkins on a MacRun Mac in short form. The iOS CI/CD pipeline guide covers what to put in the stages.
Frequently asked questions
Should a Jenkins Mac agent use SSH or inbound launch?
+
Inbound works when the Mac can reach the controller but not the other way round. With -webSocket it needs only HTTPS to the controller.
Which Java version does a Jenkins agent on macOS need?
+
The same family the controller needs. Jenkins' support policy, read in October 2026, requires Java 21 or 25 from LTS 2.555.1 on.
How many executors should a Mac agent have?
+
Start with one. Jenkins calls one executor per node the safest setting, and an Xcode build already uses every core.
Why does my Jenkins Mac agent not reconnect after a restart?
+
A LaunchAgent only runs once its user logs in. Turn on automatic login for that account and keep the plist in ~/Library/LaunchAgents.