React Native CI/CD in 2025: EAS Build, Fastlane, and OTA Updates That Actually Work
Building a React Native app manually is miserable. Xcode code signing, provisioning profiles, Android keystores, manual version bumps, uploading to TestFlight and Play Store Console - each step is fragile, rarely documented, and requires a Mac for iOS.
A proper CI/CD pipeline makes this: push code → automated tests → build for both platforms → ship to testers → ship to stores. Zero manual steps. The time investment to set it up pays back within a month.
Here's the complete setup.
The two-track release model
React Native gives you something native apps don't: the ability to update JavaScript code without going through the App Store.
| Update type | Method | Time to users | Store review |
|---|---|---|---|
| JS-only change (UI, logic, content) | OTA update (EAS Update / CodePush) | Minutes | Not required |
| Native code change (new permissions, new native module) | Store build (EAS Build) | 1-3 days (review) | Required |
Use this distinction. Bug fixes and UI changes ship as OTA updates. Changes that touch native code go through the full build and review pipeline.
Option 1: EAS Build + EAS Update (Expo ecosystem)
EAS (Expo Application Services) is the cleanest end-to-end solution for React Native CI/CD in 2025. Works for both managed and bare workflow apps.
Setup
npm install -g eas-cli
eas login
eas build:configure # Creates eas.json
// eas.json
{
"cli": {
"version": ">= 7.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true }
},
"preview": {
"distribution": "internal",
"channel": "preview",
"android": { "buildType": "apk" },
"ios": { "simulator": false }
},
"production": {
"channel": "production",
"autoIncrement": true,
"android": { "buildType": "aab" }
}
},
"submit": {
"production": {
"ios": {
"appleId": "your@apple.com",
"ascAppId": "1234567890",
"appleTeamId": "TEAMIDHERE"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal"
}
}
}
}
Code signing (the part everyone struggles with)
EAS handles code signing automatically. For iOS:
# First time: EAS creates provisioning profiles and certificates for you
eas credentials --platform ios
# EAS will:
# 1. Create App ID in Apple Developer portal
# 2. Create distribution certificate
# 3. Create provisioning profile
# 4. Store everything encrypted in EAS
For Android, generate your keystore once:
eas credentials --platform android
# EAS generates and stores your keystore securely
# CRITICAL: Download and backup your keystore - you can never update the app without it
Build commands
# Build for both platforms (runs in EAS cloud - no Mac needed for iOS)
eas build --platform all --profile production
# Build and submit to stores in one command
eas build --platform all --profile production --auto-submit
# Build preview for testers
eas build --platform all --profile preview
# Generates a QR code for installation - share with testers
OTA updates with EAS Update
npm install expo-updates
# Configure in app.json
{
"expo": {
"updates": {
"url": "https://u.expo.dev/your-project-id"
},
"runtimeVersion": { "policy": "sdkVersion" }
}
}
# Deploy JS update to preview channel
eas update --branch preview --message "Fix checkout button alignment"
# Users on preview builds get this update on next app open
# Deploy to production
eas update --branch production --message "Hotfix: payment error message"
# Users get this update within minutes, no App Store review
OTA updates apply when the app is reopened (background → foreground). The update is downloaded silently in the background. You can configure immediate restart for critical patches:
import * as Updates from 'expo-updates';
// Check and apply updates on app foreground
useEffect(() => {
const checkForUpdates = async () => {
if (!Updates.isEmbeddedLaunch) return; // Skip in development
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
// For critical updates: restart immediately
// For regular updates: prompt user or apply on next open
if (update.manifest?.extra?.critical) {
await Updates.reloadAsync();
}
}
};
const subscription = AppState.addEventListener('change', state => {
if (state === 'active') checkForUpdates();
});
return () => subscription.remove();
}, []);
Option 2: Fastlane (non-Expo projects)
For bare React Native projects or existing apps not in the Expo ecosystem, Fastlane provides equivalent automation.
gem install fastlane
cd ios && fastlane init
cd android && fastlane init
iOS Fastfile
# ios/fastlane/Fastfile
platform :ios do
desc "Build and upload to TestFlight"
lane :beta do
# Auto-increment build number from current TestFlight build
increment_build_number(
build_number: latest_testflight_build_number + 1
)
# Code signing via match (certificates stored in git repo or S3)
match(
type: "appstore",
app_identifier: "com.yourcompany.yourapp",
readonly: true
)
# Build
build_app(
workspace: "YourApp.xcworkspace",
scheme: "YourApp",
export_method: "app-store"
)
# Upload to TestFlight
upload_to_testflight(
skip_waiting_for_build_processing: true,
changelog: last_git_commit[:message]
)
# Notify team
slack(
message: "iOS beta #{lane_context[SharedValues::BUILD_NUMBER]} uploaded to TestFlight",
channel: "#releases"
) if ENV['SLACK_URL']
end
desc "Deploy to App Store"
lane :release do
match(type: "appstore", readonly: true)
build_app(workspace: "YourApp.xcworkspace", scheme: "YourApp", export_method: "app-store")
upload_to_app_store(
submit_for_review: true,
automatic_release: false, # Manual release after approval
force: true
)
end
end
Android Fastfile
# android/fastlane/Fastfile
platform :android do
lane :beta do
# Increment version code
increment_version_code(
gradle_file_path: "app/build.gradle"
)
# Build signed AAB
gradle(
task: "bundle",
build_type: "Release",
project_dir: "./"
)
# Upload to Play Store internal track
upload_to_play_store(
track: "internal",
aab: "app/build/outputs/bundle/release/app-release.aab",
json_key: "../google-service-account.json"
)
end
end
Match: team code signing
Fastlane Match solves the "who has the certificate" problem by storing all certificates and provisioning profiles in an encrypted git repository:
# Initialize match (run once per team)
fastlane match init
# Creates a private git repo for certificates
# Generate certificates (run once per environment)
fastlane match appstore
fastlane match development
# Each developer/CI runner runs:
fastlane match appstore --readonly
# Downloads and installs certificates automatically
GitHub Actions pipeline
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
tags: ['v*']
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm test -- --watchAll=false --passWithNoTests
- run: npx tsc --noEmit
ota-update:
needs: test
runs-on: ubuntu-latest
# Only on main branch pushes (not tags)
if: github.ref == 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- name: Deploy OTA update
run: eas update --branch production --message "${{ github.event.head_commit.message }}" --non-interactive
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
build-and-submit:
needs: test
runs-on: ubuntu-latest
# Only on version tags (v1.2.3)
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- name: Build and submit to stores
run: eas build --platform all --profile production --auto-submit --non-interactive
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
Flow:
- Every push to
main→ run tests → deploy OTA update to production - Every version tag (
git tag v1.2.3 && git push --tags) → run tests → full store build and submission
Environment configuration
Never hardcode API URLs, keys, or environment-specific config in the app bundle:
// Using react-native-config
import Config from 'react-native-config';
const API_URL = Config.API_URL; // Set per environment in .env files
// .env.development
// API_URL=http://localhost:3000
// .env.staging
// API_URL=https://staging-api.yourapp.com
// .env.production
// API_URL=https://api.yourapp.com
In EAS, store environment variables in project secrets:
eas secret:create --scope project --name API_URL --value "https://api.yourapp.com" --type string
Reference them in eas.json:
{
"build": {
"production": {
"env": {
"API_URL": "$API_URL",
"SENTRY_DSN": "$SENTRY_DSN"
}
}
}
}
Version strategy
# Semantic versioning: MAJOR.MINOR.PATCH
# MAJOR: breaking changes (rare for consumer apps)
# MINOR: new features
# PATCH: bug fixes
# What gets bumped:
# OTA update only → no version bump needed
# New build for stores → bump PATCH at minimum, update build number
# Major new feature → bump MINOR
# autoIncrement in eas.json handles build number automatically
# Version string is in package.json and synced to native via react-native-version or eas
Common CI/CD problems and fixes
iOS build fails: "No signing certificate found"
→ Run eas credentials --platform ios to initialize signing. If using Fastlane match: fastlane match appstore --readonly must run before build.
Android: "Keystore not found"
→ The keystore path in eas.json must be relative to the repo root, or use eas credentials to store it in EAS.
OTA update not applying to users
→ OTA updates only apply to builds on the same runtimeVersion. If you updated Expo SDK or changed native code, the runtime version changes and existing installs won't receive OTA updates - they need a new store build.
Build number not incrementing
→ autoIncrement: true in eas.json only works when submitting. For Fastlane: use increment_build_number.
A working CI/CD pipeline changes how your team ships. Features and fixes go out in hours, not days. Testers always have the latest build via TestFlight/internal track. Production hotfixes ship as OTA updates without waiting for App Store review. The upfront investment - one or two days to configure - pays back continuously.
Aunimeda develops mobile applications for iOS and Android - from MVP to production-ready apps with full backend integration.
Contact us to discuss your mobile project. See also: Mobile App Development, Mobile Game Development