Expo EAS vs Fastlane vs Bitrise: React Native CI/CD 2026
Expo EAS vs Fastlane vs Bitrise: React Native CI/CD 2026
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)
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.