The first time you ship a React Native app, the code is the easy part. The hard part is everything after git push: signing certificates that expire, provisioning profiles that don't match, a Fastlane script from a two-year-old blog post, a keystore.jks that lives on one laptop, and a build that succeeds locally on macOS but fails on Ubuntu because Xcode isn't there.
React Native CI/CD is the discipline of turning that mess into a repeatable pipeline: every push runs lint and tests, every merge to main reaches users quickly, and every release goes to TestFlight and Google Play with no one touching Xcode. In 2026, one of the cleanest ways to build that pipeline is a combination of two tools: Expo Application Services (EAS) for the native heavy lifting, and GitHub Actions for everything else.
This guide walks through a pipeline you can actually copy: what belongs on each side, how to wire them together, how to handle secrets and code signing without leaking them, and how to add over-the-air (OTA) updates so most releases skip the app stores entirely.
What "mobile app CI/CD" actually means
Mobile app CI/CD is the automated pipeline that takes a React Native or Expo commit and turns it into a signed, distributable build without a human running Xcode or Android Studio. It combines continuous integration (lint, tests, type checks on every push) with continuous delivery (signed builds and store submissions on every release), and adds an OTA update layer so JavaScript-only changes reach users in minutes instead of days.
That paragraph is the whole idea. The rest of the article is about how to implement it without spending your weekend debugging code-signing errors.
There are three moving parts in every serious pipeline:
CI checks: TypeScript, ESLint, unit tests, format checks. These are cheap, fast, and run fine on Linux.
Native builds: compiling .ipa and .aab binaries. These need macOS machines (for iOS), signing keys, and a lot of setup.
Distribution: uploading to TestFlight and Google Play internal testing, and pushing OTA updates.
The mistake most teams make is trying to do all three in the same place. GitHub Actions can compile an iOS build; it will also take 15–20 minutes per attempt on a small macOS runner, drain your included minutes at a 10x multiplier, and force you to manage certificates by hand. EAS was built to do that specific job well. The pragmatic split is: GitHub Actions owns the code, EAS owns the binary.
The split-brain problem, and why it's actually the right answer
Expo supports both approaches: you can drive EAS from any CI service, or use Expo's own EAS Workflows. Running two systems felt wrong to me at first, until I looked at what each is actually good at.
Concern GitHub Actions EAS Build / Workflows
Lint, TypeScript, unit tests Native fit, generous free tier Works, but uses paid CI minutes
iOS .ipa compilation Slow, hand-rolled signing Purpose-built
Android .aab compilation Workable but manual One command
Code signing (iOS certs, Android keystore) Stored in Actions secrets, managed by hand Managed credentials, stored encrypted and shared across the team
OTA updates (EAS Update) Triggered via CLI First-class
PR preview builds Complex eas build --profile preview
Skipping unnecessary native builds DIY Fingerprint + get-build jobs in EAS Workflows
Cost model Cheap for Linux jobs, macOS burns minutes 10x faster Plan fee plus usage-based build pricing
The rule I've settled on: if a step needs Xcode, Ruby, or a keychain, it belongs in EAS. Everything else (the fast feedback loop developers actually feel) belongs in GitHub Actions. That way a broken test blocks a PR in a couple of minutes, and a full native build only runs when it actually needs to.
Show Image Photo by Luca Bravo on Unsplash
Step 1: Set up eas.json with real build profiles
eas.json is the config file EAS reads to decide how to build your app. It lives at the root of your Expo project alongside app.json and package.json. Most tutorials show a single production profile. That's a trap. You need at least three, because dev, QA, and prod are different environments with different API URLs and distribution methods.
Here's a working starting point:
json
{
"cli": {
"version": ">= 16.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"environment": "development",
"ios": { "simulator": true }
},
"preview": {
"distribution": "internal",
"channel": "preview",
"environment": "preview"
},
"production": {
"channel": "production",
"environment": "production",
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"ascAppId": "1234567890"
},
"android": {
"track": "internal"
}
}
}
}
A few non-obvious decisions in there:
appVersionSource: "remote" delegates the build number to EAS. This is the setting that finally kills the "who bumped the version last?" merge conflict. EAS tracks build numbers server-side, and autoIncrement bumps them on production builds.
channel binds the build to an EAS Update channel. A preview-channel build only receives OTA updates published to the preview channel, so a QA release can't be accidentally overwritten by a main push.
environment tells EAS which set of EAS environment variables (development, preview, or production) to load for the build. Put values like APP_ENV or EXPO_PUBLIC_API_URL there with eas env:create instead of hardcoding them in eas.json, so builds, updates, and local dev all read the same values.
ios.simulator: true on development produces a build that runs in the iOS simulator on any Mac. Great for design review, useless on a physical device or for App Store submission. Add a second development profile without it if your team tests on real phones.
The submit block has no credentials in it. No Apple ID, no path to a Google service account JSON. Those live in EAS (see Step 3), which is what lets submission run non-interactively from CI. A serviceAccountKeyPath pointing at a gitignored file is the most common reason --auto-submit works on a laptop and fails in CI.
Full reference: Configuring EAS Build with eas.json.
Step 2: The GitHub Actions workflow
This is the file that ties everything together. It lives at .github/workflows/ci.yml. The design goal:
Every PR runs lint + tests, then kicks off a preview EAS build.
Every merge to main publishes an OTA update (added in Step 4).
Every version tag (v1.4.0) runs a production EAS build and submits it to the stores.
Store builds are deliberately tied to tags, not to every merge. A native build plus submission on every merge burns build credits, floods TestFlight with near-identical binaries, and is unnecessary when most merges are JavaScript-only.
yaml
name: CI/CD
on:
pull_request:
branches: [main]
push:
branches: [main]
tags: ["v*"]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
quality:
name: Lint, type-check, test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test -- --ci --coverage
preview-build:
name: EAS preview build
needs: quality
# Secrets are not available to PRs from forks, so skip those.
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm ci
- run: eas build --profile preview --platform all --non-interactive --no-wait
production-release:
name: EAS production build + submit
needs: quality
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm ci
- run: eas build --profile production --platform all --auto-submit --non-interactive --no-wait
Read that carefully. The shape matters more than the details.
quality runs on Ubuntu, not macOS. JS-only jobs have no business on a macOS runner. Standard Linux runners bill at about $0.006/minute versus about $0.062/minute for macOS, and macOS drains your included minutes 10x faster.
--no-wait on both build jobs. The Actions job kicks off the EAS build and returns immediately. EAS runs the build on its own infrastructure, and Actions doesn't sit there burning minutes waiting on a native compile. With --auto-submit, the submission is queued on EAS and runs when the build finishes, so you don't need to wait for it either. The trade-off: the Actions job goes green when the build is queued, not when it succeeds. Watch build status in the EAS dashboard, or drop --no-wait if you want the job to fail when the build fails.
--auto-submit on production. EAS builds the binary, then hands it directly to EAS Submit. It lands in TestFlight and the Google Play internal track on its own. Promoting it from there to public release is still a manual (and deliberate) step in App Store Connect and Play Console.
EXPO_TOKEN authenticates the runner to your Expo account. For a team, create it from a robot user with the minimum role needed rather than using a personal access token. A personal token carries everything your account can do, and it disappears when you leave the team.
npm run type-check assumes you have that script in package.json (typically tsc --noEmit).
--platform all on every PR adds up. The EAS Free plan includes 15 Android and 15 iOS builds per month. See Step 5 for how to avoid building when native code hasn't changed.
Expo's guide on triggering builds from CI goes deeper into edge cases like monorepos and other CI providers.
Step 3: Handling secrets and code signing without losing your mind
Code signing is where mobile CI/CD historically goes to die. iOS wants a distribution certificate, a provisioning profile, and a private key. Android wants a keystore, a key alias, and two passwords.
How bad is losing them? It depends on the platform. iOS certificates and profiles can always be revoked and regenerated from your Apple Developer account. Android is the scary one: if you are not enrolled in Play App Signing and you lose your keystore, you cannot update your existing app. With Play App Signing (the default for new apps), Google holds the app signing key and you can request an upload key reset, which is painful but survivable.
iOS. EAS handles this almost entirely with managed credentials. On your first interactive eas build, it offers to generate and store the certificate and provisioning profile for you. Say yes. They are stored encrypted on EAS servers and are the same for every developer and every CI run. If you have existing credentials (say, from a legacy Fastlane pipeline), you can upload them once with eas credentials.
Two things to know so CI doesn't surprise you:
Distribution certificates expire after a year. EAS does not silently replace them; regenerating requires authenticating with Apple. An expired certificate does not affect apps already on the store, only your ability to make new builds.
Non-interactive runs can't prompt for an Apple ID and 2FA code. Run eas credentials --platform ios once on your machine, create an App Store Connect API key, and choose the option to use it for EAS Submit. That key is what lets --auto-submit work from CI.
Android. EAS can generate a keystore or you can upload your existing one. For submission, EAS needs a Google Service Account key. Upload it to your project's credentials once, either in the EAS dashboard (Credentials → Android → your application identifier → Service Credentials) or with:
bash
eas credentials --platform android
Google Service Account → Upload a Google Service Account Key
Don't commit the JSON, and don't reference it with serviceAccountKeyPath in eas.json if you want CI to work. Also note that Google requires the very first upload of a new app to be done manually in Play Console before API submissions work.
Other secrets. For build-time values like SENTRY_AUTH_TOKEN or NPM_TOKEN, use EAS environment variables. The older eas secret:* commands are deprecated in favor of eas env:*:
bash
eas env:create --name SENTRY_AUTH_TOKEN --value "xxxx" \
--environment production --visibility secret
Variables have three visibility levels: plain text, sensitive, and secret. Secret values are never readable outside EAS servers, not even in the dashboard or CLI.
On the GitHub Actions side, the only secret you need is EXPO_TOKEN. All the app-signing material stays inside EAS, which means:
Signing keys never touch the GitHub runner's disk or environment, so a leaked workflow log can't expose them.
A new developer joining the team gets access via Expo organization membership, not by copying files around.
There is one source of truth for credentials instead of a base64 blob per repo.
One honest caveat: EXPO_TOKEN is still a powerful secret. Anyone who has it can trigger builds and, depending on the role behind it, manage credentials. That's another reason to use a scoped robot user, and to never expose it to workflows triggered by fork PRs.
This is the single biggest reason to keep native builds off GitHub Actions. Managing an Apple distribution certificate inside ${{ secrets.IOS_P12 }} works, until it doesn't, and then you find out on a Friday afternoon.
Show Image Photo by FLY:D on Unsplash
Step 4: Add OTA updates so most releases skip the stores
The best CI/CD pipeline is the one you don't have to run. In a mature React Native app, the large majority of changes are JavaScript-only: a copy tweak, a style fix, a new screen using components that are already in the binary. These don't need a new native build. They just need to reach existing users' phones.
That's what EAS Update is for. Add one job to the workflow:
yaml
ota-update:
name: EAS OTA update
needs: quality
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm ci
- name: Publish update
env:
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: >-
eas update --channel production --environment production
--message "$COMMIT_MESSAGE" --non-interactive
Two details in that last step are easy to get wrong:
Pass the commit message through env, never inline. Writing --message "${{ github.event.head_commit.message }}" directly in run: pastes untrusted text into a shell script. A commit message containing a quote or $(...) will break the step at best and execute arbitrary commands with access to your EXPO_TOKEN at worst.
--environment is required on SDK 55 and later. It tells eas update which EAS environment variables to bundle with. On SDK 54 and earlier, omitting it falls back to local .env files.
Now every merge to main publishes an OTA bundle within a few minutes. By default, the app downloads the update in the background on launch and applies it on the following launch, so expect users to see a change one cold start later unless you add your own reload logic with expo-updates.
If pushing every merge straight to production users makes you nervous (it should, a little), point this job at a staging channel instead and promote to production with eas update:republish, or use the rollout percentage options to release gradually.
Two caveats:
Native changes are not OTA-updatable. If you add expo-camera this morning, it needs a new binary. The runtime version protects you here: an update is only delivered to binaries with a matching runtime version. Set "runtimeVersion": { "policy": "fingerprint" } in app.json and the runtime version changes automatically whenever anything affecting the native layer changes, so a JS bundle that expects a missing native module never reaches an old binary. When that happens, cut a new tag to ship a fresh binary.
Fingerprints must be computed consistently. With the fingerprint policy, the hash calculated when you run eas update in GitHub Actions needs to match the one calculated during the EAS build. Lockfile drift or files that exist in one environment but not the other will produce a mismatch, and your update will target a runtime no binary has. If updates aren't arriving, compare fingerprints first (npx @expo/fingerprint fingerprint:generate).
Also remember the store rules: OTA updates are for fixes and incremental improvements, not for changing what your app fundamentally does after review.
Step 5: Cutting build time with fingerprints
The same fingerprint that guards your OTA updates can also save you from running native builds you don't need. The fingerprint hashes the parts of your project that affect the native binary: dependencies, app.json, native folders, config plugins. If the hash matches an existing build, the native side hasn't changed, and a new compile is wasted money.
This is not something plain eas build does for you automatically. It's a pattern you assemble in EAS Workflows from three pre-packaged job types:
fingerprint computes the hash for each platform
get-build looks for an existing build with that hash
build runs only if nothing was found (and optionally repack injects the new JS bundle into the existing binary, so testers still get an installable artifact)
yaml
.eas/workflows/pr-preview.yml
name: PR preview
on:
pull_request:
branches: [main]
jobs:
fingerprint:
type: fingerprint
get_ios_build:
needs: [fingerprint]
type: get-build
params:
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
profile: preview
build_ios:
needs: [get_ios_build]
if: ${{ !needs.get_ios_build.outputs.build_id }}
type: build
params:
platform: ios
profile: preview
Expo reports cutting its own CI build times by up to 78% with the fingerprint + repack approach. For a JS-only PR, you go from a full native compile to roughly the time it takes to compute a hash and bundle JavaScript.
If you adopt this, move the preview-build job out of GitHub Actions and into an EAS Workflow like the one above, and leave lint and tests where they are. If you'd rather stay entirely in GitHub Actions, check the expo-github-action README for its fingerprint-related sub-actions, which can compare fingerprints on a PR and decide between building and publishing an update.
Where RapidNative fits into all of this
The problem with every tutorial like this one, including this one, is that it assumes you already have a working Expo project. The wiring above is straightforward when you already have eas.json, an EXPO_TOKEN, and a project with sensible module boundaries. It's much less straightforward if you're starting from a boilerplate someone copy-pasted three years ago.
RapidNative generates Expo apps from natural-language prompts, and the export ships with the pieces that make this pipeline possible on day one:
eas.json with development, preview, and production profiles already scaffolded, matching the structure in Step 1.
app.json with the runtime version set to the fingerprint policy, so OTA updates are automatically scoped to compatible binaries.
A monorepo layout (mobile/ for the React Native app, web/ if you generated a web version). In that case, set working-directory: mobile on the workflow steps above.
<!-- VERIFY BEFORE PUBLISHING: state the Expo SDK / React Native / TypeScript versions the export currently ships with. The original draft said RN 0.81 (Expo SDK 54); as of September 2026 the current release is SDK 57, and SDK 56 shipped with RN 0.85. --> A current Expo SDK and TypeScript setup, so you're not starting your pipeline with an SDK upgrade.
The reason this matters: we kept watching teams get a great AI-generated app, then lose two days rebuilding the project's scaffolding into something CI could actually consume. The whole reason RapidNative uses Expo over bare React Native is that Expo is the shortest path from "code exists" to "binary is signed and on TestFlight."
If you're not using RapidNative, none of this pipeline requires it. If you are, you can drop the workflow above into .github/workflows/ci.yml, add an EXPO_TOKEN, run one interactive eas build to set up credentials, and push. You can try RapidNative here.
Show Image Photo by Redd Francisco on Unsplash
FAQ
How much does EAS Build cost compared to GitHub Actions?
As of September 2026, EAS has a Free plan with 15 Android and 15 iOS builds per month, a Starter plan at $19/month that includes $45 of build credit, and a Production plan at $199/month that includes $225 of build credit and two build concurrencies. Beyond the included credit, builds are billed per build based on platform and machine size.
GitHub Actions is free for public repos and includes 2,000 minutes/month for private repos on the Free plan (3,000 on Pro and Team). macOS runners consume those minutes at a 10x multiplier, and overage on standard runners is about $0.006/minute for Linux versus $0.062/minute for macOS.
On raw compute alone, GitHub Actions is often the cheaper option: a 20-minute iOS build on a standard macOS runner is a little over a dollar. What you're paying EAS for is everything around the compile: managed credentials, submission, internal distribution links, OTA hosting, and not maintaining Fastlane and a CI keychain. Price out your own build volume, and count engineer hours honestly when you do.
Can I use GitHub Actions without EAS for React Native?
Yes. You'd need macOS runners with Xcode, a CI-managed keychain, Fastlane (or equivalent) for signing and upload, and a lot of YAML. It's a valid choice for teams with existing native mobile expertise who don't want a vendor dependency. There's also a middle path: eas build --local runs the EAS build process on your own runner. For most React Native teams, especially those already on Expo modules, the maintenance cost of a hand-rolled pipeline ends up exceeding the price of EAS fairly quickly.
Do I need EAS Workflows if I already use GitHub Actions?
EAS Workflows is Expo's own CI/CD product. If your repo is mostly mobile and you want one dashboard, plus pre-packaged jobs like fingerprint, get-build, repack, submit, and Maestro tests, use Workflows. If you have a broader repo (backend services, web app, mobile app) and GitHub Actions is already the source of truth, keep it and use EAS for build, submit, and update. A hybrid (tests in Actions, native jobs in Workflows) works fine too. Note that Workflows jobs consume EAS CI minutes on your plan.
What breaks first when a React Native CI pipeline goes wrong?
In rough order of frequency: expired or mismatched iOS provisioning profiles, Android keystore password confusion after a team member leaves, EXPO_TOKEN belonging to the wrong account or a user who left, submission failing in CI because Apple or Google credentials only existed on someone's laptop, and native module additions reaching users over OTA because the runtime version was pinned by hand instead of fingerprinted. Managed credentials in EAS take most of the pain out of the first two; the rest are process problems solved by a robot user, credentials stored in EAS, and the fingerprint policy.
The point of all this
A mobile CI/CD pipeline is a boring, un-fun piece of infrastructure that has an outsized effect on how fast you can ship. When it works, no one notices. When it doesn't, every release is a two-day fire drill and your team stops shipping between store submissions.
The pipeline in this article is deliberately not the most sophisticated one possible. There's no matrix build across Node versions, no Slack-notified rollout gates, no per-branch environment promotion. Those are worth adding later, but only after the core loop (push → tested → signed → shipped) runs on its own.
If you're building the pipeline from scratch, start with the eas.json in Step 1 and the workflow in Step 2. If you're building it into an existing project, budget a day for the first successful production build and submission (mostly iOS signing and store credentials) and another day for the OTA update wiring.
Either way, the goal is the same: a git push that ends with your users getting the new version, and nobody touching Xcode along the way.
Top comments (0)