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"]
end
The 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 & links
Three 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
end
This 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
end
5.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
end
The 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"]
end
Each 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_LANE
CircleCI 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
)
end
The 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
)
end
8.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
)
end
For 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 --> WLApp
In 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_WhiteLabelCustomer
11. 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.