Skip to main content

Production-Style Mobile CI/CD for React Native with CircleCI, Fastlane and White-Label Apps

·1667 words·8 mins
Stanislav Cherkasov
Author
Stanislav Cherkasov
{DevOps,DevSecOps,Platform} Engineer
Table of Contents
examples - This article is part of a series.
Part : This Article

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:

  1. Multiple environments

    • qa, staging, production
  2. Two platforms

    • Android (Gradle)
    • iOS (Xcode)
  3. Two distributions per platform

    • Beta → Firebase App Distribution
    • Production → Google Play / App Store & TestFlight
  4. 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:

  1. deploy_beta - for feature branches / internal builds
  2. deploy_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:

  1. Build the app for the correct flavor/scheme
  2. Generate release notes (via a small shell script)
  3. 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, or production)
  • 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:

  1. Android - add a productFlavor with:

    • New applicationId
    • New signingConfig
    • Brand resources (name, icon, colors)
  2. iOS - add:

    • Xcode scheme
    • Bundle ID
    • GoogleService.<Customer>-Info.plist if needed
  3. Fastlane - create a new profile class under BuildsWhiteLabel with:

    • BUNDLEID, XCODESCHEME, GOOGLESERVICEPLIST, MATCHGITBRANCH, etc.
  4. CircleCI - add jobs that point to:

    • android whitelabel_NewCustomer
    • ios 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:

  1. Android/iOS flavors/schemes for qa and production.
  2. A single firebase_qa lane for each platform.
  3. 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.

examples - This article is part of a series.
Part : This Article