Back to blog
FILE 0xDE·SHIPPING AN IOS BUILD HEADLESSLY OVER SSH

Shipping an iOS build headlessly over SSH

May 9, 2026 · ios, ci, codesigning

I wanted to build and install my iOS app from a remote agent session — no Xcode window open, nobody at the Mac. xcodebuild over SSH refused to codesign. Here's the workaround that ended up clean enough to use in production.

The symptom

error: errSecInternalComponent
Command CodeSign failed with a nonzero exit code

The login keychain was unlocked. The signing identity was installed. Manual builds from a Terminal window worked fine. But the same build launched over SSH died at the codesign step.

The root cause

An SSH session's security context can't reach the GUI login keychain even when it's unlocked, because the SecurityAgent that fronts the keychain is a per-session daemon tied to the GUI login. SSH lands in a different audit session. launchctl asuser would let me push a job into the GUI session, but it requires root and I didn't have passwordless sudo set up.

The workaround

Drop a one-shot LaunchAgent into the GUI session of UID 501 (the console login), let it run the build inside that session where the keychain is properly available, then tear it down.

ssh user@mac 'cat > /tmp/build.sh << "EOF"
#!/bin/bash
set -x
exec > /tmp/build.log 2>&1
cd "$HOME/code/myapp"
/usr/bin/xcodebuild \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -configuration Release \
    -destination "generic/platform=iOS" \
    -derivedDataPath /tmp/myapp-build \
    -allowProvisioningUpdates clean build
EOF
chmod +x /tmp/build.sh

cat > ~/Library/LaunchAgents/com.example.build.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.example.build</string>
<key>ProgramArguments</key><array>
  <string>/bin/bash</string><string>/tmp/build.sh</string>
</array>
<key>RunAtLoad</key><true/>
</dict></plist>
EOF

launchctl bootout gui/501/com.example.build 2>/dev/null
launchctl bootstrap gui/501 ~/Library/LaunchAgents/com.example.build.plist'

Poll /tmp/build.log for "BUILD SUCCEEDED" or whatever finish-marker your build produces. Then install over SSH (this part is happy in any session because devicectl doesn't need keychain access):

ssh user@mac 'xcrun devicectl device install app \
  --device <device-uuid> \
  /tmp/myapp-build/Build/Products/Release-iphoneos/MyApp.app'

Clean up:

ssh user@mac '
  launchctl bootout gui/501/com.example.build 2>/dev/null
  rm -f ~/Library/LaunchAgents/com.example.build.plist /tmp/build.*'

Why this works

launchctl bootstrap gui/501 <plist> schedules the job in the GUI session of user 501 — the same session your console login is in. The build runs as that user, in that audit session, with access to the unlocked keychain. RunAtLoad: true makes it kick off immediately. The job runs to completion and exits.

Installing the app via devicectl doesn't need the keychain, so the ordinary SSH session does that part fine.

What I'd do differently

The polling for "build complete" is the ugliest part. Instead of tail-watching the log, I'd give the build script a final line that writes a sentinel file (touch /tmp/build.done) and poll for that. Or use KeepAlive: false plus launchctl print gui/501/<label> to detect job exit. Either is cleaner than parsing log output.

The same pattern works for any iOS project on the Mac that hits codesign issues over SSH. If you have multiple iOS apps to build remotely, generalize the script — pass the project path and scheme as arguments — and you have a portable "remote iOS build" recipe.