This article is for engineers who want to build their iOS app in Xcode on a home Mac and push it to TestFlight — entirely from a smartphone while out and about. I'll show the complete procedure for multi-hop SSHing from a smartphone → hub laptop (WSL2) → a separate Mac, then completing archive → IPA → TestFlight upload in a single command. I also cover why codesign stalls over SSH and the workaround (a dedicated build keychain), plus the full contents of all 3 deployment scripts.
What you can do with this
- Push a new TestFlight build with a single SSH command from a smartphone (or from a WSL2 terminal)
- Use the Mac only as a runtime for Xcode and
xcodebuild— no touching it day-to-day - Use App Store Connect API Key (
.p8) auth so no 2FA dialog pops up - Runs entirely within a home LAN — no VPS or GitHub Actions needed (also a good second life for a Mac mini or old MacBook)
4 approaches compared
| Setup | SSH + custom scripts (this article) |
Xcode on Mac directly | GitHub Actions (macOS) | fastlane-driven |
|---|---|---|---|---|
| Monthly cost | $0 (home Mac electricity only) | $0 | macOS runners are expensive per minute | $0 (home Mac) |
| Editor | Any WSL2 editor (VS Code, vim, etc.) | Xcode | Any | Any |
| Time per release | 3–6 min (archive + IPA + altool) | 5–10 min (via GUI) | 10–15 min after push (includes CI startup) | 3–6 min |
| Secret storage | Mac local (~/.appstoreconnect) |
Mac local | GitHub Secrets | Mac local / Match |
Architecture diagram
Editor + git push + SSH origin
▼ ssh + bash scripts/upload.sh
build.keychain-db / xcodebuild / altool
What I use
- WSL2 machine: bash and Git on Ubuntu, ssh client
- Mac: any Mac with Xcode 26.x installed. A Mac mini or old MacBook is fine. Normally only accessed via SSH — no GUI interaction required
- Apple Developer Program: $99/year. Issue an Apple Distribution certificate and a Provisioning Profile for TestFlight distribution in advance
- App Store Connect API Key (
.p8): issue from App Store Connect → Users and Access → Integrations. Requires App Manager role or higher
Why setup_build_keychain.sh is needed
xcodebuild archive runs fine in the Mac's Terminal normally, but codesign fails over SSH with "could not open keychain" — that's the starting problem this article solves.
The cause: the login keychain, even when unlocked over SSH, is inaccessible to codesign. Without a GUI login session, there's no security agent bound to it, so the ACL's "allow without application confirmation" list can't engage — it tries to show an interactive prompt and fails.
The solution is to create a dedicated build keychain, duplicate the Apple Distribution certificate into it, and set the ACL so codesign can use it without any GUI confirmation. Run scripts/setup_build_keychain.sh once in the Mac's Terminal (a GUI session).
Replace all placeholders in the code below with your own values ({kcpw} is an arbitrary string used to unlock the build keychain locally — it's not an external secret):
{user} Your Mac login username
{mac_addr} Mac IP or hostname (e.g. 192.168.x.y)
{app_name} App name (must match Xcode scheme / archive name)
{bundle_id} Bundle ID (e.g. com.example.MyApp)
{team_id} Apple Developer Team ID (10 characters)
{kcpw} Build keychain local key (any string)
{provisioning_profile_name} Provisioning Profile name
#!/bin/bash
set -euo pipefail
KCNAME="build.keychain"
KCPATH="$HOME/Library/Keychains/${KCNAME}-db"
KCPW="{kcpw}" # build keychain unlock password (local container key, any string)
EXPORTPW="$(uuidgen)" # .p12 temp encryption key (only used within this script)
if [[ -f "$KCPATH" ]]; then
echo "build keychain already exists: $KCPATH"; exit 1
fi
# 1. Create the build keychain
security create-keychain -p "$KCPW" "$KCNAME"
security set-keychain-settings -lut 21600 "$KCNAME"
security unlock-keychain -p "$KCPW" "$KCNAME"
# 2. Export identity from login keychain (click "Always Allow" when prompted)
TMPP12="$(mktemp -t build-cert).p12"
trap 'rm -f "$TMPP12"' EXIT
security export -k "$HOME/Library/Keychains/login.keychain-db" \
-t identities -f pkcs12 -P "$EXPORTPW" -o "$TMPP12"
# 3. Import into build keychain, grant codesign/productbuild access via -T
security import "$TMPP12" -k "$KCNAME" -P "$EXPORTPW" \
-T /usr/bin/codesign -T /usr/bin/security \
-T /usr/bin/productbuild -T /usr/bin/productsign
# 4. Set key partition list to suppress GUI prompts
security set-key-partition-list \
-S 'apple-tool:,apple:,codesign:' -s -k "$KCPW" "$KCNAME"
# 5. Add to front of search list
ORIGINAL_LIST="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g' | tr '\n' ' ')"
security list-keychains -d user -s "$KCNAME" $ORIGINAL_LIST
security find-identity -v -p codesigning "$KCNAME"
The critical step is #4, set-key-partition-list. Adding apple-tool: / apple: / codesign: to the partition access list is what allows codesign tools to use the key without a GUI prompt — this is the mechanism that makes certificate access work over SSH.
archive.sh — Generate a Release archive non-interactively
Script #2. Runs xcodebuild archive over SSH. Unlocks the build keychain first and sets a 6-hour auto-lock.
#!/bin/bash
set -euo pipefail
APP_NAME="{app_name}" # ← replace with your app name
KCPW="{kcpw}" # ← same local key used in setup_build_keychain.sh
ARCHIVE_PATH="${ARCHIVE_PATH:-/tmp/${APP_NAME}.xcarchive}"
BUILD_KCPW="${BUILD_KCPW:-${KCPW}}"
WORKSPACE="${WORKSPACE:-$(pwd)/${APP_NAME}.xcworkspace}"
if [[ ! -f "$HOME/Library/Keychains/build.keychain-db" ]]; then
echo "✗ build.keychain-db not found. Run setup_build_keychain.sh first." >&2
exit 1
fi
# Unlock build keychain (6h auto-lock)
security unlock-keychain -p "$BUILD_KCPW" build.keychain
security set-keychain-settings -lut 21600 build.keychain
# Archive
rm -rf "$ARCHIVE_PATH"
LOG=/tmp/archive.log
xcodebuild \
-workspace "$WORKSPACE" \
-scheme "$APP_NAME" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIVE_PATH" \
archive \
> "$LOG" 2>&1 || true
if grep -q "ARCHIVE SUCCEEDED" "$LOG"; then
echo "✓ ARCHIVE SUCCEEDED -> $ARCHIVE_PATH"
else
echo "✗ ARCHIVE FAILED (log: $LOG)"
grep -B1 -A4 -E 'error:|errSec|FAILED' "$LOG" | tail -30
exit 1
fi
Output goes to /tmp/archive.log and success is determined by looking for the ARCHIVE SUCCEEDED string. xcodebuild's stdout is verbose — reading it raw over SSH is exhausting.
ExportOptions.plist — Explicitly specify Manual signing
Configuration file for exporting an IPA from the archive. After getting burned by Auto signing a few times, I fixed it to Manual signing with an explicit profile name and Team ID. Safe to commit to git — no secrets involved.
<?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>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>{team_id}</string>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>{bundle_id}</key>
<string>{provisioning_profile_name}</string>
</dict>
<key>uploadSymbols</key><true/>
<key>compileBitcode</key><false/>
<key>stripSwiftSymbols</key><true/>
</dict>
</plist>
Replace the Bundle ID and profile name with your own values. The profile name must match what you created in the Apple Developer portal.
upload.sh — Export IPA + upload via altool
Script #3. Reuses the archive to produce an IPA, then uploads to TestFlight via xcrun altool. Using App Store Connect API Key auth means no 2FA dialog.
#!/bin/bash
set -euo pipefail
APP_NAME="{app_name}" # ← replace with your app name
KCPW="{kcpw}" # ← same local key used in setup_build_keychain.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ARCHIVE_PATH="${ARCHIVE_PATH:-/tmp/${APP_NAME}.xcarchive}"
EXPORT_DIR="${EXPORT_DIR:-/tmp/${APP_NAME}-export}"
EXPORT_OPTIONS="${EXPORT_OPTIONS:-$SCRIPT_DIR/ExportOptions.plist}"
ALTOOL_ENV="${ALTOOL_ENV:-$PROJECT_ROOT/Config/altool.env}"
BUILD_KCPW="${BUILD_KCPW:-${KCPW}}"
# Load ASC_KEY_ID / ASC_ISSUER_ID from Config/altool.env
source "$ALTOOL_ENV"
# Auto-call archive.sh if archive doesn't exist yet (reuse if it does)
if [[ ! -d "$ARCHIVE_PATH" ]]; then
bash "$SCRIPT_DIR/archive.sh"
fi
# Export IPA (exportArchive re-signs, so unlock keychain again)
security unlock-keychain -p "$BUILD_KCPW" build.keychain
rm -rf "$EXPORT_DIR"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
> /tmp/export.log 2>&1 || true
IPA_PATH="$(find "$EXPORT_DIR" -maxdepth 1 -name '*.ipa' | head -n1)"
[[ -f "$IPA_PATH" ]] || { echo "✗ IPA export failed"; exit 1; }
# Upload to TestFlight
xcrun altool --upload-app -f "$IPA_PATH" -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
Config/altool.env is in .gitignore and contains just 2 lines:
ASC_KEY_ID={your_key_id}
ASC_ISSUER_ID={your_issuer_id}
The .p8 file itself goes in ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8 — altool checks this path automatically.
Shipping to TestFlight in one line from WSL2
With all of the above in place, the day-to-day workflow from WSL2 is a single line:
# After bumping CFBundleVersion and git push:
ssh {user}@{mac_addr} 'cd ~/path/to/{app_name} && bash scripts/upload.sh'
3–6 minutes later, a new build appears in TestFlight. Once App Store Connect finishes processing, you can install it on your iPhone immediately.
Common sticking points
codesignshows a GUI prompt and hangs: you forgotset-key-partition-listin the build keychain setup. Make sure to specifyapple-tool:,apple:,codesign:- Auto signing falls back to a Development cert from a different team: for Release, stick with Manual signing and an explicit Team ID. Set
signingStyle = manualinExportOptions.plist - Build keychain auto-locks after 6 hours and the next day's build fails: call
security unlock-keychainat the start of botharchive.shandupload.sh. A singleset-keychain-settings -lut 21600on its own isn't enough - SPM dynamic frameworks don't get Embedded: source-distributed dynamic frameworks like RealmSwift need to be manually added to Embed Frameworks in
project.pbxproj. Firebase / GoogleMobileAds XCFramework binary targets auto-embed, so this one's easy to miss - altool prompts for 2FA: password auth triggers 2FA blocking. Always use App Store Connect API Key (
.p8) auth - Mac sshd not running: System Settings → General → Sharing → Remote Login — enable it
FAQ
Q. Does the Mac need to stay awake all the time?
A. If you only hit it when shipping, enable Wake-on-LAN and "Wake for network access" in System Settings — an SSH packet will wake it. For always-on use, configure it to not sleep when the lid is closed while on AC power (caffeinate -s or System Settings).
Q. How do I issue an Apple Distribution certificate?
A. In Xcode on the Mac: Settings → Accounts → Manage Certificates → "Apple Distribution." This registers it in the login keychain. Running setup_build_keychain.sh duplicates it into the build keychain.
Q. Do I need to update the scripts when the Provisioning Profile is renewed?
A. Not if the profile name stays the same. Download the renewed profile from the Apple Developer portal, open it on the Mac, and it's applied. No script changes needed.
Q. I want to use a Mac mini as a dedicated archive server
A. An M2 or M4 Mac mini archives Xcode 26 + SPM projects in 3–5 minutes. Always-on power consumption is around 10W. Pair it with build completion notifications to Slack or LINE and you'll know the moment it's done while you're out.
Q. Would fastlane be faster?
A. fastlane shines for team dev — match for certificate sync and lane structure for workflow. The scripts in this article are deliberately scoped to "solo shipping from one Mac" — a thin reimplementation of a small slice of what fastlane offers. If you move to team development, migrating to fastlane is worth it.
Note: The steps in this article were verified on Xcode 26 / macOS 15 as of May 2026. Apple Distribution certificate issuance steps and the App Store Connect UI may change. If something doesn't work, please let me know in the comments.
Wrap-up
Running Xcode fully remotely from WSL2 over SSH is straightforward once you have the two key pieces in place: a build keychain and an App Store Connect API Key. Three shell scripts (setup_build_keychain.sh / archive.sh / upload.sh) and one ExportOptions.plist is all it takes. The Mac sits in the background as a dedicated Xcode archive machine, and you do normal development in your preferred Linux environment.
If this article was helpful, I'd love it if you shared it on X (Twitter).
App by the author of this blog
I made an iOS reading management app called My Bookstore. Simple bookshelf management — give it a try.
Related articles
- Run Claude Code from Your Smartphone with tmux + Tailscale + Termius [Hub-Centric] — SSH routing from smartphone to hub covered here
- Generating AI Blog Images with a Local GPU [SDXL + IP-Adapter + img2img]
References
- Apple — Distributing your app for beta testing and releases
- App Store Connect API Key issuance guide
- TN2415: Xcode Help — exportArchive and ExportOptions.plist
- man security (1) — keychain operations reference
Note: This article is part of an automated blog update experiment using Claude Code.
No comments:
Post a Comment