Shipping a mobile app once is easy.
Shipping it every day, to multiple environments (QA, staging, production), for two platforms (iOS + Android), and then duplicating it as a white-label app for a customer - that’s where things get ugly quickly.
In this post I’ll walk through a production-style CI/CD setup for a React Native app that solves exactly that:
- CircleCI for pipelines
- Fastlane for iOS/Android automation
- Firebase App Distribution for betas
- Play Store / App Store for production
- A white-label flow that reuses the same codebase and pipeline
This article is based on the demo repo I used for a conference talk. The examples are simplified, but the patterns are real and ready to adapt to your own project.
🎥 If you prefer video first: this post follows the same ideas I show in my talk: YouTube GithubRepo
1. The Problem We’re Solving#
We want to support:
Multiple environments
qa,staging,production
Two platforms
- Android (Gradle)
- iOS (Xcode)
Two distributions per platform
- Beta → Firebase App Distribution
- Production → Google Play / App Store & TestFlight
White-labeling
- A second branded app, with different bundle IDs, signing, icons, etc.
- Still built from the same repository and pipeline
The goal: one CI/CD pipeline that handles all of this with minimal duplication and predictable behavior.
2. High-Level Architecture#
Here’s the big picture: a developer push kicks off CI, which runs tests and fans out into platform-specific Fastlane lanes that take care of distribution.
flowchart TD
Dev["Developer pushes to Git"] --> CI["CircleCI workflow"]
CI --> Tests["JS tests (Jest, etc.)"]
Tests --> BuildAndroid["Fastlane: Android lane"]
Tests --> BuildIOS["Fastlane: iOS lane"]
BuildAndroid --> BetaAndroid["Firebase App Distribution (Android)"]
BuildIOS --> BetaIOS["Firebase App Distribution (iOS)"]
BuildAndroid --> PlayStore["Google Play (internal / production)"]
BuildIOS --> AppStore["App Store / TestFlight"]
subgraph WhiteLabel["White-label apps"]
BuildAndroid --> WLPlay["White-label app on Google Play"]
BuildIOS --> WLStore["White-label app on App Store"]
endThe important part: white-label apps are not special in the pipeline. They are just another build profile with its own identifiers and signing.
3. Repository Layout (Conceptual)#
You don’t need anything exotic in your repo. A typical structure looks like this:
.
├── android/
│ ├── app/
│ │ └── build.gradle # productFlavors, signingConfigs
│ └── keystores/
│ └── README.md # how to generate & use keystores
├── ios/
│ └── Product.xcworkspace # Xcode workspace / project
├── fastlane/
│ ├── Fastfile # main lanes
│ ├── Matchfile # certificate / profile config
│ └── helpers/
│ └── CompanyFastlane.rb # build profiles (QA/Staging/Prod/WhiteLabel)
├── .circleci/
│ └── config.yml # CI workflows
└── docs/
└── README.md # extra notes & linksThree main layers work together:
- Platform configs (
android/,ios/) - Build logic (Fastlane + helper Ruby classes)
- Pipelines (CircleCI workflows)
4. Step 1 - Define Environments and Flavors#
4.1 Android: productFlavors#
Android flavors let us create multiple “apps” from the same module.
android {
// ...
productFlavors {
production {
dimension "version"
signingConfig signingConfigs.release
resValue "string", "app_name", "Product"
}
qa {
dimension "version"
signingConfig signingConfigs.release
applicationIdSuffix ".qa"
resValue "string", "app_name", "Product QA"
}
staging {
dimension "version"
signingConfig signingConfigs.release
applicationIdSuffix ".staging"
resValue "string", "app_name", "Product Staging"
}
// White-label example
WhiteLabelCustomer {
dimension "version"
signingConfig signingConfigs.release_branded
applicationId "com.whitelabel.customer"
resValue "string", "app_name", "WhiteLabel Customer"
}
}
}Key ideas:
- Each flavor has its own
applicationId(important for Play Store and Firebase). - Each flavor can use a different
signingConfig(separate keystore for the white-label). - We reuse the same code, but generate different apps.
4.2 iOS: Schemes & Bundle IDs#
On iOS, we usually combine:
- Schemes (
Product.qa,Product.staging,Product,WhiteLabelCustomer) - Bundle identifiers (
com.company.Product.qa,com.whitelabel.customer, …)
To avoid hardcoding that in multiple places, I like to keep it in a Ruby helper used by Fastlane:
class CompanyFastlane
XCODEPROJECT = 'ios/Product.xcodeproj'
XCODEWORKSPACE = 'ios/Product.xcworkspace'
class BuildsInternal < CompanyFastlane
class Qa < BuildsInternal
BUNDLEID = 'com.company.Product.qa'
GOOGLESERVICEPLIST = 'GoogleService.qa-Info.plist'
XCODESCHEME = 'Product.qa'
end
class Production < BuildsInternal
BUNDLEID = 'com.company.Product'
GOOGLESERVICEPLIST = 'GoogleService.release-Info.plist'
XCODESCHEME = 'Product'
end
end
class BuildsWhiteLabel < CompanyFastlane
GYMEXPORTMETHOD = 'app-store'
MATCHTYPE = 'appstore'
class WhiteLabelCustomer < BuildsWhiteLabel
BUNDLEID = 'com.whitelabel.customer'
GOOGLESERVICEPLIST = 'GoogleService.WhiteLabelCustomer-Info.plist'
XCODESCHEME = 'WhiteLabelCustomer'
MATCHGITBRANCH = 'WhiteLabelCustomer'
end
end
endThis gives us a single source of truth for:
- Bundle IDs
- Schemes
- Firebase configs
- Match branches / export methods
5. Step 2 - Model Builds in Fastlane#
Now that flavors & schemes exist, we need Fastlane lanes that know how to build and ship each environment.
We use:
- Public lanes (entry points) e.g.
android firebase_qa - Private lanes to share behavior, e.g.
common_build,distribution_firebase
5.1 Android lanes#
Conceptually:
platform :android do
before_all do
ensure_env_vars(env_vars: ['FIREBASE_CLI_TOKEN'])
sh "./helpers/beta_msg.sh > helpers/beta_msg_placeholder.txt"
end
desc "QA beta build → Firebase"
lane :firebase_qa do
common_build(customer: CompanyFastlane::BuildsInternal::Qa)
distribution_firebase(customer: CompanyFastlane::BuildsInternal::Qa)
end
desc "Production build → Play Store"
lane :release do
common_build(customer: CompanyFastlane::BuildsInternal::Production)
distribution_playmarket(customer: CompanyFastlane::BuildsInternal::Production)
end
desc "White-label production build → Play Store"
lane :whitelabel_WhiteLabelCustomer do
common_build(customer: CompanyFastlane::BuildsWhiteLabel::WhiteLabelCustomer)
distribution_playmarket(customer: CompanyFastlane::BuildsWhiteLabel::WhiteLabelCustomer)
end
end5.2 iOS lanes#
Symmetric to Android:
platform :ios do
desc "QA beta build → Firebase App Distribution"
lane :firebase_qa do
common_build(customer: CompanyFastlane::BuildsInternal::Qa)
distribution_firebase(customer: CompanyFastlane::BuildsInternal::Qa)
end
desc "Production build → TestFlight / App Store"
lane :release do
common_build(customer: CompanyFastlane::BuildsInternal::Production)
distribution_testflight(customer: CompanyFastlane::BuildsInternal::Production)
end
desc "White-label production build → TestFlight / App Store"
lane :whitelabel_WhiteLabelCustomer do
common_build(customer: CompanyFastlane::BuildsWhiteLabel::WhiteLabelCustomer)
distribution_testflight(customer: CompanyFastlane::BuildsWhiteLabel::WhiteLabelCustomer)
end
endThe beauty: white-label is “just another customer” for the same private lanes.
6. Step 3 - CircleCI Workflows (Beta + Production)#
CircleCI glues everything together and decides when to run which lane.
We usually have two workflows:
deploy_beta- for feature branches / internal buildsdeploy_production- for integration / main branches
6.1 Overview of workflows#
flowchart TD
subgraph BetaWorkflow["deploy_beta"]
A["Push to non-main branch"] --> NodeBeta["nodejs_setup (tests)"]
NodeBeta --> QAApprove["approve_beta_qa (manual)"]
NodeBeta --> StagingApprove["approve_beta_staging (manual)"]
QAApprove --> AndroidBetaQA["android_beta_qa"]
QAApprove --> IOSBetaQA["ios_beta_qa"]
StagingApprove --> AndroidBetaStaging["android_beta_staging"]
StagingApprove --> IOSBetaStaging["ios_beta_staging"]
end
subgraph ProductionWorkflow["deploy_production"]
B["Push to integration / main"] --> NodeProd["nodejs_setup (tests)"]
NodeProd --> AndroidApprove["android_approve (manual)"]
NodeProd --> IOSApprove["ios_approve (manual)"]
AndroidApprove --> AndroidDeploy["android_deploy"]
AndroidApprove --> AndroidDeployWL["android_deploy_WhiteLabelCustomer"]
IOSApprove --> IOSDeploy["ios_deploy"]
IOSApprove --> IOSDeployWL["ios_deploy_WhiteLabelCustomer"]
endEach CircleCI job is basically:
android_deploy:
<<: *android_flow_deploy
environment:
FASTLANE_LANE: "android release"
android_deploy_WhiteLabelCustomer:
<<: *android_flow_deploy
environment:
FASTLANE_LANE: "android whitelabel_WhiteLabelCustomer"
ios_deploy:
<<: *ios_flow_deploy
environment:
FASTLANE_LANE: "ios release"
ios_deploy_WhiteLabelCustomer:
<<: *ios_flow_deploy
environment:
FASTLANE_LANE: "ios whitelabel_WhiteLabelCustomer"The job itself simply runs something like:
bundle exec fastlane $FASTLANE_LANECircleCI doesn’t need to know about bundle IDs, schemes or environments - that’s all encapsulated in Fastlane.
7. Step 4 - Beta Distribution with Firebase App Distribution#
For beta builds we want:
- Quick feedback from QA / stakeholders
- Automatic release notes
- Traceability back to commits / PRs
A typical beta lane will:
- Build the app for the correct flavor/scheme
- Generate release notes (via a small shell script)
- Upload to Firebase with
firebase_app_distribution
Conceptually:
lane :distribution_firebase do |options|
app_id = options[:customer]::ANDROID_FIREBASE_APP_QA # or environment-specific
firebase_app_distribution(
app: app_id,
testers: "qa-team@example.com",
release_notes_file: options[:customer]::FIREBASERELEASENOTESFILE
)
endThe release notes script can pull:
- Branch name
- Build number
- PR URL
- CircleCI build URL
- Last commit message
8. Step 5 - Production Distribution: Play Store & App Store#
Production lanes are almost the same, but:
- On Android we push to a track (
internal,beta, orproduction) - On iOS we upload to TestFlight and optionally promote to the App Store
8.1 Android → Google Play#
Using supply (or upload_to_play_store):
lane :distribution_playmarket do |options|
supply(
package_name: options[:customer]::BUNDLEID,
track: 'internal',
skip_upload_screenshots: true,
skip_upload_images: true
)
end8.2 iOS → TestFlight / App Store#
Using gym + pilot:
lane :distribution_testflight do |options|
gym(
scheme: options[:customer]::XCODESCHEME,
export_method: options[:customer]::GYMEXPORTMETHOD
)
pilot(
app_identifier: options[:customer]::BUNDLEID,
distribute_external: false
)
endFor white-label customers we often use a separate Match branch and app-store export method, which is exactly what BuildsWhiteLabel::WhiteLabelCustomer controls.
9. Step 6 - White-Labeling: Concept and Shape#
Let’s zoom in on the white-label aspect.
We want:
- Single codebase
- Multiple branded apps
- Single CI/CD pipeline
Conceptually:
flowchart LR
Code["Shared React Native codebase"] --> CoreApp["Core product app"]
Code --> WLApp["White-label customer app"]
CoreApp --> CoreBeta["Core - Firebase beta"]
CoreApp --> CoreStores["Core - stores"]
WLApp --> WLBeta["White-label - Firebase beta"]
WLApp --> WLStores["White-label - stores"]
subgraph Config["Configuration layer"]
Flavors["Android flavors"]
Schemes["iOS schemes"]
Profiles["Fastlane profiles"]
CIJobs["CircleCI jobs"]
end
Flavors --> CoreApp
Flavors --> WLApp
Schemes --> CoreApp
Schemes --> WLApp
Profiles --> CoreApp
Profiles --> WLApp
CIJobs --> CoreApp
CIJobs --> WLAppIn practice, adding a new white-label app means:
Android - add a
productFlavorwith:- New
applicationId - New
signingConfig - Brand resources (name, icon, colors)
- New
iOS - add:
- Xcode scheme
- Bundle ID
GoogleService.<Customer>-Info.plistif needed
Fastlane - create a new profile class under
BuildsWhiteLabelwith:BUNDLEID,XCODESCHEME,GOOGLESERVICEPLIST,MATCHGITBRANCH, etc.
CircleCI - add jobs that point to:
android whitelabel_NewCustomerios whitelabel_NewCustomer
And you’re done. No extra “pipeline per customer”, just new configuration plugged into the same pipeline.
10. Local Usage & Example Commands#
All of this is still convenient to run locally for debugging:
# Install Ruby deps
bundle install
# Install JS deps
yarn install
# Android QA beta → Firebase
bundle exec fastlane android firebase_qa
# Android production → Play Store
bundle exec fastlane android release
# Android white-label production
bundle exec fastlane android whitelabel_WhiteLabelCustomer
# iOS QA beta → Firebase
bundle exec fastlane ios firebase_qa
# iOS production → TestFlight
bundle exec fastlane ios release
# iOS white-label production
bundle exec fastlane ios whitelabel_WhiteLabelCustomer11. Takeaways#
A few key patterns I’d highlight:
Separate “what to build” from “how to build”
- Environments & brands live in flavors/schemes and Ruby profile classes.
- Lanes and CI jobs stay generic.
Let Fastlane own the complexity
- CircleCI only needs to know which lane to call.
White-label apps are config, not forks
- New customer? Add a profile + flavor + scheme + jobs.
- Same code, same pipeline, different branding & signing.
If you already have a React Native app and some Fastlane in place, you don’t have to adopt everything at once. Start with:
- Android/iOS flavors/schemes for
qaandproduction. - A single
firebase_qalane for each platform. - Later: add production, then white-label customers as needed.
If you’d like, I can also share a minimal starter Fastfile and config.yml that you can drop into a fresh project and evolve from there.
