Ship an Expo app to TestFlight without EAS
EAS Build is the easy path — but sometimes you can’t use it: you don’t want the
subscription, you’ve made custom native changes, or your CI owns the signing. You can do the whole
thing locally with expo prebuild + xcodebuild. Here’s the pipeline, and
the parts that bite.
1. Prebuild the native project
npx expo prebuild --platform ios --no-install
This generates ios/ from your config. (Keep ios/ out of the repo.)
2. Pods — with UTF-8, or CocoaPods crashes
cd ios
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pod install
Some Ruby versions choke on a non-UTF-8 locale here; setting LANG/LC_ALL
avoids the cryptic crash.
3. Archive (Release)
xcodebuild archive \
-workspace ios/YourApp.xcworkspace \
-scheme YourApp \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath build/YourApp.xcarchive \
-allowProvisioningUpdates \
DEVELOPMENT_TEAM=XXXXXXXXXX \
CODE_SIGN_STYLE=Automatic
-allowProvisioningUpdates lets Xcode use the Apple ID already signed in, so you
don’t need an API key or manually managed profiles.
4. Export + upload
xcodebuild -exportArchive \
-archivePath build/YourApp.xcarchive \
-exportOptionsPlist ios/ExportOptions.plist \
-exportPath build/export \
-allowProvisioningUpdates
ExportOptions.plist:
<?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>destination</key><string>upload</string>
<key>teamID</key><string>XXXXXXXXXX</string>
<key>uploadSymbols</key><false/>
</dict></plist>
Then the build shows up in App Store Connect → TestFlight.
The gotchas that cost the most time
- Xcode license. If
xcodebuildrefuses, runsudo xcodebuild -license acceptonce. - Deployment target. Pods that still target iOS < 15 error out. Add a
post_installinios/Podfileforcing every pod target to 15.1. - Bundle id must match
app.json’sios.bundleIdentifier. - Build number. Setting
CURRENT_PROJECT_VERSIONon thexcodebuildcommand line does not change the archived version — the Info.plist has it hardcoded. SetCFBundleVersionin the archiveInfo.plist’sApplicationProperties(and the app’s Info.plist), then re-export. uploadSymbols=false— React Native’s prebuilt frameworks ship without dSYMs; uploading symbols just errors.
None of this is exotic — it’s just a sequence the tooling doesn’t spell out. Script it once
(release_ios.sh) and never think about it again.
Get new posts
One email when a new post ships. No spam.
A ready-made release_ios.sh (and the Android upload script)
ships in the Expo Production
Starter.