Ship to TestFlight
Link the EAS project, move your keys to EAS, build the iOS app and get it to TestFlight testers - by hand first, then from a git tag.
Hey - by the end of this page your iOS app is on TestFlight, built and uploaded by EAS, and the people you invite can install it from the TestFlight app. This page is iOS only; the Android side is Ship to Google Play, and it reuses the EAS project you link here.
EAS builds everything native in the cloud (EAS docs). Run the CLI
as bunx eas-cli <command> - substitute that for bare eas in Expo's docs.
Before you start
- Tier: Starter or Pro. The Free tier ships without
eas.json- runbunx eas-cli build:configureonce to create one, and skip thebun run doctorchecks. - Time: about an hour of your own work, plus 20-40 minutes per cloud build, 5-15 minutes of Apple processing, and - if you're not enrolled yet - a day or two for Apple to approve your developer account.
- Accounts: the Apple Developer Program (paid, yearly) and a free Expo account.
- Previous tutorial: Deploy your API routes if your app has a backend - otherwise Make it yours is the one that matters here.
1. Finish your branding first
The name, bundle id, icon and splash are baked into the binary, and the bundle id can't change once the app is on App Store Connect. Work through Make it yours before the first store build, then:
bun run doctorYou should see: no warnings for app.name, the com.acme.app ids, or empty urls - the
Settings screen links to urls.privacy, urls.terms and urls.support, and App Review opens
them. Using universal links? Set links and run bun run gen:links now too - the associated
domains are native as well (Config โ Universal links).
2. Link the EAS project
bunx eas-cli login
bunx eas-cli init
bunx eas-cli update:configureeas init can't write into a dynamic app.config.ts, so it only prints the project id.
Paste it into app.easProjectId in readynative.config.ts (in CI you can set EAS_PROJECT_ID
instead; the env var wins). If you did this in Add push notifications, the id
is already there. app.config.ts derives extra.eas.projectId and updates.url
(https://u.expo.dev/<projectId>) from it. If the project belongs to an organisation rather
than your personal account, also set app.owner to its slug.
Set the project id before the first production build
app.config.ts omits updates entirely while the project id is empty. A build made that way
never receives an over-the-air update, and the only fix is shipping a new build.
bun run doctorYou should see: green rows for eas whoami, "EAS project linked" and
"app.config.ts updates.url".
3. Put your keys on EAS
.env is gitignored and never uploaded, so an EAS build doesn't see a single key from it. Store
each one in the EAS environment the build uses - production for the store build, preview
for preview builds. EXPO_PUBLIC_* keys are public by design (they're compiled into the app),
so plaintext is fine; anything without the prefix is a server or build secret and gets
sensitive:
bunx eas-cli env:set --name EXPO_PUBLIC_SUPABASE_URL --value https://xxxx.supabase.co --environment production --visibility plaintext
bunx eas-cli env:set --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value eyJ... --environment production --visibility plaintext
bunx eas-cli env:set --name SENTRY_AUTH_TOKEN --value sntrys_... --environment production --visibility sensitiveThose are examples - set every key your .env has, with production values where they differ
(the production Supabase project, pk_live_โฆ, and so on). APP_VARIANT and
EXPO_PUBLIC_APP_VARIANT are already set per profile in eas.json. The details are in
Environment variables โ On EAS.
bunx eas-cli env:list --environment productionYou should see: every key from your .env, with production values.
4. Try a preview build on your phone (optional)
eas.json ships three profiles. Each sets APP_VARIANT, which is how app.config.ts picks
the name and bundle-id suffix (Config):
| Profile | Installs as | Distribution | Channel |
|---|---|---|---|
development | .dev, "(Dev)" | internal (dev build) | development |
preview | .preview, "(Preview)" | internal (ad hoc) | preview |
production | your id, your name | store | production |
A preview build is a release build you install from a link - the quickest way to see the real thing before App Review. Internal iOS builds install only on registered devices, so register your phone first (the command shows a page to scan with the phone):
bunx eas-cli device:create
bunx eas-cli build --profile preview --platform iosThe first iOS build asks you to sign in with your Apple Developer account and lets EAS create the certificates and provisioning profile. A device you register later needs a rebuild.
You should see: a QR code and install link when the build finishes; the app installs as "YourApp (Preview)" next to any other variant.
5. Build for the App Store
bunx eas-cli build --profile production --platform iosproduction uses your bundle id as is and bumps the build number on every build
(autoIncrement, with cli.appVersionSource: "remote", so EAS owns the number). The export
compliance question is pre-answered: app.config.ts sets ITSAppUsesNonExemptEncryption: false.
You should see: the build marked Finished on expo.dev, with an .ipa artifact.
6. Submit it
bunx eas-cli submit --profile production --platform ios --latestThat uploads the latest production build to App Store Connect. On the first run, EAS asks for
your Apple ID and creates the app record if it doesn't exist. To skip the prompts next time,
fill in appleId, ascAppId and appleTeamId in submit.production.ios in eas.json (it's
empty on purpose). store.appStoreId in readynative.config.ts is something else - it feeds
the in-app review prompt and store links, not the submission.
You should see: after 5-15 minutes of Apple processing, the build under App Store Connect โ your app โ TestFlight โ iOS Builds, and an email from Apple saying it's ready.
7. Invite testers
TestFlight has two kinds of groups:
- Internal testing - people on your App Store Connect team, up to 100. No review: they get the build as soon as it's processed. In TestFlight, click + next to Internal Testing, name the group, and add testers.
- External testing - anyone with an email or a public link, up to 10,000. The first build of each version goes through Beta App Review (usually about a day), and you need a beta description and a feedback email. App Store Connect lets you create an external group only once an internal one exists.
Once a group exists, submit straight to it next time:
bunx eas-cli submit --profile production --platform ios --latest --groups "QA Team" --what-to-test "New onboarding"You should see: testers get an email invite, install the TestFlight app, and your app appears there with an Install button.
8. Automate it
A release is a version bump plus a v* tag. npm version does both - it needs a clean git tree,
bumps version in package.json, commits and tags v<version>:
npm version patch
git push --follow-tagsapp.config.ts takes the app version from package.json, and the runtime version follows it
(runtimeVersion.policy: "appVersion"), so over-the-air updates published after a bump reach
only builds with the new version. Then build and submit in one command, which submits through
eas.json โ submit.production:
bunx eas-cli build --profile production --platform ios --auto-submitYour tree ships no CI workflows. To run this on every v* tag (or a preview build on every pull
request), add your own - see Expo's GitHub Actions guide.
You should see: a production build on expo.dev that submits itself when it finishes.
Check it
bun run doctorEvery EAS row should be green. Before you go from TestFlight to the App Store, run
bun run doctor --store (Pro) and read Pass app review.
If it doesn't work
- The build fails on credentials or provisioning - see EAS build or credentials fail.
- The app works locally but shows "Configure โฆ" on TestFlight (or no prices) - a key is missing from the EAS environment. See A key works locally but not in an EAS build.
- The preview build won't install on a phone - that device wasn't registered when the build
ran.
bunx eas-cli device:create, then build again. eas submitsays the bundle id is taken - someone else's app already uses it. Changeapp.ios.bundleIdinreadynative.config.tsand build again.- The build never shows up in TestFlight - check the email Apple sends after processing; a missing usage description or an invalid icon is reported there, not in EAS.
More in Troubleshooting.
Congrats ๐
Your app is on TestFlight, built in the cloud with production keys, and the next release is a tag away. Next, the Android side: Ship to Google Play.
Deploy your API routes
Put Expo Router API routes on EAS Hosting, give them server keys, smoke-test /api/health, and point every build profile at the deployed URL.
Ship to Google Play
Build an Android App Bundle on EAS, create the app in Play Console, set up a service account, then submit to internal testing with one command.