TL;DR
Shipping a React Native app to the App Store and Google Play every two weeks used to require a Mac, Xcode, Android Studio, and days of setup. CI/CD for mobile has matured significantly. Expo EAS (Expo Application Services) is the modern default for Expo-managed and bare React Native apps — cloud builds, over-the-air JavaScript updates, and App Store submission in one unified CLI. Fastlane is the automation layer — a Ruby-based tool that automates the repetitive steps (building, signing, screenshots, upload) and can run locally, in GitHub Actions, or any CI server. Bitrise is the mobile-first CI/CD platform — a hosted CI service with 300+ pre-built mobile steps, dedicated Mac machines, and tight integration with App Store Connect and Google Play. For Expo apps: EAS, no question. For custom native projects needing automation scripts: Fastlane. For teams that want managed CI infrastructure with mobile-specific features: Bitrise.
Key Takeaways
- EAS Build runs in the cloud — no Mac required for iOS builds
- EAS Update pushes JS-only changes in seconds — bypass App Store review for most updates
- Fastlane
deliver— upload builds + metadata + screenshots to App Store in one command - Bitrise has M1/M2 Mac machines — faster native builds than GitHub Actions
- EAS pricing: 30 builds/month free, then $3/build (iOS) or $0.06/minute
- Fastlane is free and open-source — you pay for whatever CI runs it
- Bitrise Hobby plan: free for 1 app with 500 build minutes/month
Mobile CI/CD Challenges
Without CI/CD — manual release process:
1. Pull latest code
2. Run `cd ios && pod install`
3. Open Xcode, update version + build number
4. Set signing certificate (correct profile for production?)
5. Archive → Validate → Upload to App Store Connect
6. Wait 30 minutes for processing
7. Repeat for Android (Gradle, keystore, Play Console)
8. Track down why the signing certificate expired
Reality: 2-4 hours per release, one developer blocked
With CI/CD:
git push origin main → build triggers → both stores updated → done
5 minutes of human time
Expo EAS: Cloud-Native Mobile CI/CD
EAS provides cloud build infrastructure, OTA updates, and store submission — designed specifically for Expo and React Native.
Installation
npm install -g eas-cli
eas login
eas build:configure # Creates eas.json
eas.json Configuration
{
"cli": {
"version": ">= 7.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"resourceClass": "m-medium"
}
},
"preview": {
"distribution": "internal",
"channel": "preview",
"android": {
"buildType": "apk"
}
},
"production": {
"channel": "production",
"ios": {
"resourceClass": "m-medium"
},
"android": {
"buildType": "app-bundle"
},
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"appleId": "developer@company.com",
"ascAppId": "1234567890",
"appleTeamId": "ABCD123456"
},
"android": {
"serviceAccountKeyPath": "./google-play-key.json",
"track": "internal"
}
}
},
"update": {
"channel": "production"
}
}
Build Commands
# iOS build (no Mac required — runs in EAS cloud)
eas build --platform ios --profile production
# Android build
eas build --platform android --profile production
# Both platforms simultaneously
eas build --platform all --profile production
# Development build (includes expo-dev-client for testing native modules)
eas build --platform ios --profile development
# Internal distribution (TestFlight alternative — direct IPA install)
eas build --platform ios --profile preview
Code Signing (Automatic)
# EAS manages certificates and provisioning profiles automatically
eas credentials
# Option: Let EAS manage everything (recommended)
eas build --platform ios --profile production
# → "Would you like us to automatically manage your credentials? y"
# → EAS creates and stores certificates in Expo's key storage
OTA Updates (EAS Update)
# Push JavaScript-only update (no rebuild needed)
# Users get the update on next app launch
eas update --channel production --message "Fix checkout bug"
# Preview channel for testing
eas update --channel preview --message "New feature preview"
# Rollback to previous update
eas update:roll-back-to-embedded --channel production
// expo-updates in your app — check and download updates
import * as Updates from "expo-updates";
async function checkForUpdates() {
if (!__DEV__) {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
}
}
// Automatic update on launch (configure in app.json)
// expo.updates.checkAutomatically: "ON_LOAD" | "ON_ERROR_RECOVERY" | "WIFI_ONLY" | "NEVER"
GitHub Actions with EAS
# .github/workflows/eas-build.yml
name: EAS Build
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Setup EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build on EAS
run: eas build --platform all --non-interactive --profile production
- name: Submit to stores
run: eas submit --platform all --non-interactive --profile production
EAS Update in CI/CD
# .github/workflows/ota-update.yml
name: OTA Update (JS-only changes)
on:
push:
branches: [main]
paths:
- "app/**"
- "components/**"
- "hooks/**"
- "!**/*.native.*" # Not native code
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: eas update --channel production --message "${{ github.event.head_commit.message }}" --non-interactive
Fastlane: Automation Scripts for Native Builds
Fastlane is a collection of Ruby-based tools that automate iOS and Android release steps — certificate management, building, testing, and upload.
Installation
# macOS (required for iOS Fastlane)
brew install fastlane
# In your React Native project
cd ios && fastlane init
cd android && fastlane init
iOS Fastfile
# ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
before_all do
# Ensure we're on the right branch and code is clean
ensure_git_branch(branch: "main")
ensure_git_status_clean
end
desc "Sync certificates and profiles (Match)"
lane :sync_certificates do
match(
type: "appstore", # "development" | "adhoc" | "appstore"
app_identifier: "com.company.myapp",
git_url: "git@github.com:company/certificates.git", # Private cert repo
readonly: is_ci, # Don't modify certs on CI
)
end
desc "Build and upload to TestFlight"
lane :beta do
sync_certificates
# Increment build number automatically
increment_build_number(
build_number: latest_testflight_build_number + 1,
xcodeproj: "MyApp.xcodeproj"
)
# Build
build_app(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
configuration: "Release",
export_method: "app-store",
)
# Upload to TestFlight
upload_to_testflight(
api_key_path: "./app_store_connect_api_key.json",
skip_waiting_for_build_processing: true,
notify_external_testers: false,
)
# Notify Slack
slack(
message: "iOS beta uploaded to TestFlight! ✅",
slack_url: ENV["SLACK_WEBHOOK_URL"],
)
end
desc "Submit to App Store"
lane :release do
sync_certificates
increment_version_number(version_number: ENV["VERSION"])
increment_build_number(build_number: Time.now.strftime("%Y%m%d%H%M"))
build_app(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
configuration: "Release",
)
deliver(
api_key_path: "./app_store_connect_api_key.json",
submit_for_review: true,
automatic_release: false,
force: true, # Skip HTML report
precheck_include_in_app_purchases: false,
metadata_path: "./fastlane/metadata",
screenshots_path: "./fastlane/screenshots",
)
end
error do |lane, exception|
slack(
message: "Build failed in #{lane}: #{exception.message}",
success: false,
slack_url: ENV["SLACK_WEBHOOK_URL"],
)
end
end
Android Fastfile
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Build and upload to Google Play Internal Testing"
lane :beta do
gradle(
task: "bundle",
build_type: "Release",
project_dir: "android/",
properties: {
"android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
"android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["KEY_PASSWORD"],
}
)
upload_to_play_store(
track: "internal",
aab: "./android/app/build/outputs/bundle/release/app-release.aab",
json_key: "./google-play-key.json",
skip_upload_metadata: true,
skip_upload_screenshots: true,
)
end
end
Fastlane in GitHub Actions
# .github/workflows/fastlane-ios.yml
name: iOS Release
on:
push:
tags: ["v*.*.*"]
jobs:
build:
runs-on: macos-14 # M1 Mac runner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: |
npm ci
cd ios && pod install
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.2"
bundler-cache: true # Runs bundle install automatically
- name: Run Fastlane
run: cd ios && bundle exec fastlane beta
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY: ${{ secrets.ASC_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Bitrise: Mobile-First CI/CD Platform
Bitrise is a hosted CI/CD platform with dedicated mobile build machines, 300+ pre-built Steps, and tight integration with the mobile app stores.
bitrise.yml (Workflow Definition)
# bitrise.yml — Bitrise workflow configuration
format_version: "13"
default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git
app:
envs:
- NODEJS_VERSION: "20"
- IOS_SCHEME: MyApp
- ANDROID_MODULE: app
- ANDROID_VARIANT: release
workflows:
# Run on every PR — tests only
pull-request:
steps:
- git-clone@8: {}
- nvm@1:
inputs:
- node_version: $NODEJS_VERSION
- npm@1:
inputs:
- command: ci
- npm@1:
inputs:
- command: run test
- npm@1:
inputs:
- command: run lint
# iOS production build
ios-release:
meta:
bitrise.io:
machine_type_id: g2-m1.4core # M1 Mac (4 vCPU, 12GB RAM)
stack: osx-xcode-15
steps:
- git-clone@8: {}
- nvm@1:
inputs:
- node_version: $NODEJS_VERSION
- npm@1:
inputs:
- command: ci
- cocoapods-install@2: {}
- certificate-and-profile-installer@1: {}
- xcode-archive@5:
inputs:
- project_path: ios/MyApp.xcworkspace
- scheme: $IOS_SCHEME
- distribution_method: app-store
- export_method: app-store
- automatic_code_signing: api-key
- deploy-to-itunesconnect-application-loader@1:
inputs:
- api_key_path: $BITRISEIO_ASC_API_KEY_FILE_URL
- api_key_id: $ASC_KEY_ID
- api_key_issuer_id: $ASC_ISSUER_ID
- slack@3:
inputs:
- webhook_url: $SLACK_WEBHOOK_URL
- channel: "#releases"
- message: "iOS build deployed to TestFlight ✅"
# Android production build
android-release:
meta:
bitrise.io:
machine_type_id: elite
stack: linux-docker-android-20
steps:
- git-clone@8: {}
- nvm@1:
inputs:
- node_version: $NODEJS_VERSION
- npm@1:
inputs:
- command: ci
- android-build@1:
inputs:
- project_location: android
- module: $ANDROID_MODULE
- variant: $ANDROID_VARIANT
- sign-apk@1:
inputs:
- android_app: $BITRISE_AAB_PATH
- keystore_url: $BITRISEIO_ANDROID_KEYSTORE_URL
- keystore_password: $ANDROID_KEYSTORE_PASSWORD
- key_alias: $ANDROID_KEY_ALIAS
- key_password: $ANDROID_KEY_PASSWORD
- google-play-deploy@3:
inputs:
- package_name: com.company.myapp
- track: internal
- service_account_json_key_path: $BITRISEIO_GOOGLE_PLAY_KEY_URL
Feature Comparison
| Feature | Expo EAS | Fastlane | Bitrise |
|---|---|---|---|
| Cloud builds | ✅ | ❌ (runs on your CI) | ✅ |
| Mac required | ❌ | ✅ (for iOS) | ❌ |
| OTA updates | ✅ EAS Update | ❌ | ❌ |
| Cert management | ✅ Automatic | ✅ Match | Manual |
| Store submission | ✅ EAS Submit | ✅ deliver/supply | ✅ |
| Screenshot automation | ❌ | ✅ snapshot | ❌ |
| Non-Expo apps | Bare RN only | ✅ Any native | ✅ Any native |
| Pricing (free tier) | 30 builds/month | Free (OSS) | 500 min/month |
| M1/M2 Mac machines | ✅ | Via GitHub Actions | ✅ |
| Pre-built mobile steps | ❌ | ❌ | 300+ |
| Expo-specific | ✅ Best | ❌ | ❌ |
When to Use Each
Choose Expo EAS if:
- Your app is built with Expo (managed or bare workflow)
- No Mac available — EAS builds iOS in the cloud without one
- OTA JavaScript updates are important for your release process
- You want the simplest possible setup:
eas build,eas update,eas submit
Choose Fastlane if:
- Full native project (not Expo) with custom build configurations
- Screenshot automation for App Store listings is needed (
fastlane snapshot) - You want automation scripts that run locally AND on any CI (GitHub Actions, CircleCI)
- Complete ownership of the build process — no third-party CI dependency
Choose Bitrise if:
- Dedicated mobile CI/CD with M1/M2 Mac machines without managing GitHub Actions MacOS runners
- 300+ pre-built Steps reduce configuration time for common mobile workflows
- Team needs a mobile-specific CI dashboard with build insights and test reports
- Complex multi-step workflows (build → test → screenshot → upload → notify)
Code Signing: The Hidden Complexity of Mobile CI/CD
iOS code signing is the most common reason mobile CI/CD pipelines break. Unlike web deployments, shipping an iOS app requires a provisioning profile (which device/distribution channel is authorized), a signing certificate (which developer/team created the build), and an app ID that matches both. These credentials expire, need renewal, and must match across local dev machines and CI environments.
EAS handles this automatically. When you run eas build for the first time, EAS creates and stores the certificates and provisioning profiles in Expo's secure key storage. Certificates are renewed automatically before expiry. The developer never interacts with Apple's developer portal for routine signing — EAS manages it. For teams where the original signing developer left the company or where certificates exist in multiple states across machines, EAS's managed signing is a significant operational simplification.
Fastlane's match tool takes a different approach: it stores all signing assets (certificates, provisioning profiles) in a private git repository, encrypted with a passphrase. Every team member and CI runner decrypts the same assets from the same source. This avoids the "signing works on my machine but not CI" problem because everyone uses identical credentials. The tradeoff is that someone must set up the certificate repository initially, and the passphrase must be rotated if a team member leaves. For teams with existing native iOS infrastructure, match is the industry-standard solution.
Bitrise provides code signing storage in its dashboard — upload your provisioning profile and certificate once, reference them by file path in your workflow, and Bitrise installs them before each build. This is simpler than managing a match repository but less transparent: the signing assets are opaque to developers and fully dependent on Bitrise's infrastructure.
OTA Updates and Release Channel Strategy
EAS Update's over-the-air update system is architecturally distinct from what any CI/CD tool does. While EAS Build and Fastlane and Bitrise all produce the same output (a binary for the App Store), EAS Update lets you push JavaScript bundle updates to production users without a new binary release, bypassing App Store review entirely.
The practical limit is significant: OTA updates can only change JavaScript code, not native modules. Adding a new npm package that includes native code (a camera library, a Bluetooth module, a map SDK) still requires a full native rebuild and App Store submission. Pure JavaScript logic changes, UI updates, bug fixes, and most feature work can be shipped via OTA. For most product teams, 70-80% of releases are JavaScript-only and can bypass the App Store queue.
EAS channels — typically production, preview, and staging — control which update a given build receives. A build configured for the preview channel only downloads updates published to preview. This lets you test OTA updates with a specific audience before publishing to production. Rollback is immediate: eas update:roll-back-to-embedded reverts users to the bundle baked into the installed binary, and the rollback takes effect on the next app launch without waiting for an App Store update approval.
Neither Fastlane nor Bitrise has an OTA equivalent — both are build-and-submit tools. If you need rapid JavaScript iteration cycles without App Store latency, EAS is the only option in this comparison.
Pricing at Scale
For small teams, all three tools offer meaningful free tiers. At scale, the economics diverge significantly.
EAS charges per build: free for 30 builds/month on the free plan, then roughly $3 per iOS build or $0.06/minute on paid plans. A team shipping weekly iOS and Android builds across three environments (development, preview, production) might trigger 50-100 builds per month. At 75 builds/month, EAS costs roughly $135-225/month at pay-as-you-go rates. EAS Update pushes are free with no volume cap on the free tier (within fair-use limits), which dramatically reduces the need for full builds if most changes are JavaScript-only.
Fastlane is free and open-source — you pay only for whatever CI executes it. GitHub Actions provides 2,000 free minutes/month on the free plan and $0.08/minute for macOS runners after that. A 20-minute iOS build on macOS costs $1.60 on GitHub Actions. Bitrise's hobby plan gives 500 build minutes/month free; the Developer plan at ~$36/month includes 4,000 minutes with M1 machines. For high-volume teams, Bitrise's per-seat pricing can be more predictable than EAS per-build or GitHub Actions per-minute.
The calculation that often tips teams toward EAS: if your engineers are spending hours managing code signing, handling failed builds, or waiting on App Store review for JavaScript fixes, EAS's cost is recovered quickly in engineering time. Fastlane is cheaper at scale but requires more DevOps investment upfront.
Hybrid Approaches
EAS and Fastlane are not mutually exclusive. A common pattern for Expo apps with custom native modules: use EAS Build for the cloud build infrastructure (no Mac required, automatic signing), and add Fastlane deliver for metadata management — screenshots, release notes, and App Store listing updates that EAS Submit doesn't fully automate. Another pattern: use Fastlane snapshot (automated screenshot testing across device sizes) to generate App Store screenshots, then EAS Submit to upload the final binary. Combining tools by responsibility — EAS for building, Fastlane for metadata — takes the best of both without the operational cost of running Bitrise for everything.
Methodology
Data sourced from official Expo EAS documentation (docs.expo.dev/eas), Fastlane documentation (fastlane.tools), Bitrise documentation (devcenter.bitrise.io), GitHub star counts as of February 2026, npm download statistics, pricing pages as of February 2026, and community discussions from the Expo Discord, r/reactnative, and the Bitrise community forums.
Related: RevenueCat vs Adapty vs Superwall for the in-app purchase infrastructure that ships inside these builds, or React Native MMKV vs AsyncStorage vs Expo SecureStore for local storage in the apps you're shipping.
See also: React vs Vue and React vs Svelte