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.