ReadyNative

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 - run bunx eas-cli build:configure once to create one, and skip the bun run doctor checks.
  • 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 doctor

You 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).

bunx eas-cli login
bunx eas-cli init
bunx eas-cli update:configure

eas 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 doctor

You 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 sensitive

Those 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 production

You 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):

ProfileInstalls asDistributionChannel
development.dev, "(Dev)"internal (dev build)development
preview.preview, "(Preview)"internal (ad hoc)preview
productionyour id, your namestoreproduction

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 ios

The 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 ios

production 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 --latest

That 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-tags

app.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-submit

Your 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 doctor

Every 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 submit says the bundle id is taken - someone else's app already uses it. Change app.ios.bundleId in readynative.config.ts and 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.

On this page

Get ReadyNative