devShakib

Build Flavors Done Right, Before the Third Environment Bites You

Flutter build flavors done right: compile time environment config with dart define from file, Android product flavors, iOS schemes, per flavor Firebase, and CI.

A staging build once shipped to the App Store pointing at our production database. Nobody caught it until a test user's fake order landed in a real client's live dashboard, and I spent a Friday night in Dubai reversing writes by hand.

The cause was embarrassingly small: a const bool isProd in main.dart that someone flipped for a local test and forgot to flip back. That single line is why I have strong opinions about Flutter build flavors. Two environments you can fake with a boolean flag and mostly get away with it. Three is where the flag approach quietly rots into a maze of if-checks, and the environment you actually ship becomes whatever value happened to be committed when CI ran.

This post is about doing multi-environment configuration properly in Flutter: compile-time isolation, zero runtime branching on environment, and three builds that sit on one phone without knowing the others exist. I'll cover Android product flavors, iOS schemes, --dart-define-from-file, per-flavor Firebase, CI wiring, and a safe migration path if you're already deep in the swamp.

Why a boolean environment flag breaks at the third environment

Here's the pattern I keep inheriting. Someone needs a staging server, so they write this:

const bool isStaging = false;String get apiBaseUrl {  if (isStaging) return 'https://staging.api.example.com';  return 'https://api.example.com';}

It works with two environments. Then a third appears — a demo build for a client, a white-label instance, a QA sandbox. isStaging isn't enough anymore, so it becomes a string, and that string gets read in fifteen places. Firebase init needs a branch. The analytics key needs a branch. The deep-link scheme needs a branch. The Stripe publishable key needs a branch. Six months later you have a config.dart that's 300 lines of ternaries, and which environment actually ships is decided by whichever value was committed last.

The problems compound:

The fix is to stop deciding the environment at runtime. Decide it at build time, bake exactly one config into the binary, and give each build its own identity. Everything below is a consequence of that one principle.

How Flutter flavors map to Android product flavors and iOS schemes

"Flavor" is Flutter's umbrella term, but under the hood it delegates to the native build systems. On Android a flavor maps to a Gradle product flavor. On iOS it maps to an Xcode scheme plus a build configuration. If those three names don't line up, you get failures that are miserable to debug — Xcode silently falling back to a Runner scheme, Gradle complaining about a missing flavor. So keep the names identical everywhere: dev, staging, prod.

Android product flavors in build.gradle

In android/app/build.gradle (or build.gradle.kts with the Kotlin DSL — the concepts are identical):

android {    flavorDimensions "environment"    productFlavors {        dev {            dimension "environment"            applicationIdSuffix ".dev"            resValue "string", "app_name", "MyApp Dev"        }        staging {            dimension "environment"            applicationIdSuffix ".staging"            resValue "string", "app_name", "MyApp Staging"        }        prod {            dimension "environment"            resValue "string", "app_name", "MyApp"        }    }}

The applicationIdSuffix is what lets all three installs coexist — more on that below. The resValue overrides the app name per flavor so you can tell them apart on the home screen.

iOS schemes and build configurations in Xcode

On iOS the work is in Xcode: duplicate the build configurations (Debug-dev, Release-dev, Profile-dev, and the same for staging and prod), create one scheme per flavor, and point each scheme at its matching configuration. Then set PRODUCT_BUNDLE_IDENTIFIER per configuration through a user-defined build setting rather than hardcoding it three times. I usually push the flavor-specific values into a .xcconfig file per configuration so the settings are diffable in git instead of buried in the .pbxproj.

It's fiddly the first time and I won't pretend otherwise. Do it once, write down the steps for the next person, never touch it again.

Running and building a specific flavor

flutter run --flavor dev --dart-define-from-file=config/dev.jsonflutter build apk --flavor prod --dart-define-from-file=config/prod.jsonflutter build ipa --flavor staging --dart-define-from-file=config/staging.json

The rule I hold the line on: a build without a flavor should fail, not default to prod. A silent default is a footgun waiting for a tired engineer at 11pm — and I've been that engineer. We'll enforce this in code and in CI later.

Compile-time config with --dart-define and --dart-define-from-file

Flavors give you three separate builds. --dart-define gives those builds their configuration, injected at compile time, with no config file shipped inside the bundle. The injected values become compile-time constants, which means the Dart compiler can tree-shake branches that reference them — dead code for the environment you didn't build simply isn't in the binary.

The clumsy way is passing values one by one on the command line (--dart-define=API_BASE_URL=...), which gets unreadable fast. The clean way, since Flutter 3.7, is --dart-define-from-file pointing at a JSON file per environment.

config/dev.json:

{  "ENV": "dev",  "API_BASE_URL": "https://dev.api.example.com",  "SENTRY_DSN": ""}

Then read the values in Dart with String.fromEnvironment, and wrap them in one typed config class so nothing else in the app ever reaches for the raw environment values:

enum Env { dev, staging, prod }class AppConfig {  static const _envName = String.fromEnvironment('ENV', defaultValue: '');  static const apiBaseUrl = String.fromEnvironment('API_BASE_URL');  static const sentryDsn = String.fromEnvironment('SENTRY_DSN');  static Env get env => switch (_envName) {        'dev' => Env.dev,        'staging' => Env.staging,        'prod' => Env.prod,        _ => throw StateError('ENV not set — build with --dart-define-from-file'),      };  static bool get isProd => env == Env.prod;}

Two things matter here.

First, _envName defaults to empty and then throws, so a misconfigured build crashes immediately at startup rather than limping along silently in production. Loud failures are a feature, not a rudeness. A crash on the first launch in QA is infinitely cheaper than a wrong-environment build that looks fine until real money moves through it.

Second, these are const. AppConfig.isProd resolves at compile time and the compiler drops dead branches. There is no runtime if deciding which environment you're in — the environment is a property of the binary itself. Note the distinction: String.fromEnvironment only produces a compile-time constant when read from a const context, which is exactly why AppConfig's fields are declared static const rather than computed at runtime.

One door, not forty

Never let feature code read String.fromEnvironment directly. Everything goes through AppConfig. When a value gets renamed or a new environment appears, you change one file, not forty call sites. A config layer with a single door is easy to reason about; one with forty doors is how the swamp grew in the first place. This is the same discipline you'd apply to any cross-cutting concern — centralize the seam, keep the blast radius small.

Keeping secrets out of the compiled binary and the repo

Here's the mistake I see most often: treating --dart-define as if it were secure. It is not encryption. Anything you inject ends up as a plain string in the compiled binary, and anyone with the APK and ten minutes with strings or a decompiler can read it back out. iOS is no different. So be precise about what a "secret" actually is.

The second category doesn't belong in a mobile app at all. It belongs behind your backend. A client I onboarded had shipped an admin API key through dart-define because "it was easier," and that key could delete any user in their system. We rotated it and moved the operation server-side inside the same week. Easier is not a security model.

For the config files themselves, my convention:

# .gitignoreconfig/prod.jsonconfig/*.local.json

Keys that grant real power live in your secret manager (GitHub Actions secrets, GCP Secret Manager, whatever you use) and never touch the repo. And the uncomfortable rule: if you're not sure who has seen a key, it's already compromised — rotate it.

App IDs, icons, and names so all three flavors live on one device

This is the quality-of-life change your testers will thank you for. When each flavor has a distinct application ID, all three install side by side. QA can hold staging and prod next to each other on the same phone without uninstalling anything between test runs, and screenshots in bug reports become self-identifying.

The applicationIdSuffix on Android and the per-configuration PRODUCT_BUNDLE_IDENTIFIER on iOS handle the unique ID. Now make the builds visually distinct so nobody files a "prod bug" that was actually staging:

Widget wrapWithEnvBadge(Widget child) {  if (AppConfig.isProd) return child;  return Banner(    message: AppConfig.env.name.toUpperCase(),    location: BannerLocation.topStart,    child: child,  );}

Because AppConfig.isProd is a compile-time constant, the tree-shaker removes the Banner path entirely from prod — there's no runtime cost and no risk of the badge accidentally showing to real users. The number of times a colored banner has killed a false bug report pays for the whole setup on its own. On one team we cut "wait, is this a real bug?" triage messages roughly in half just by making the environment obvious on screen. Cheap intervention, absurd return.

Firebase per flavor without leaking prod data into staging

Firebase is where flavor discipline earns its keep, because a leak here means test data sitting in your real database — or worse, a load test hammering your production quota. The rule: one Firebase project per environment. Separate projects for dev, staging, and prod. Not one project with clever collection prefixes — real isolation, with separate Firestore security rules, separate quotas, separate billing, separate analytics streams. Shared projects always leak eventually, usually through an index or a rule you forgot applied to both.

The old way was juggling multiple google-services.json and GoogleService-Info.plist files per build. The modern way uses the FlutterFire CLI to generate a firebase_options.dart per flavor:

flutterfire configure \  --project=myapp-dev \  --out=lib/firebase_options_dev.dart \  --ios-bundle-id=com.example.myapp.dev \  --android-package-name=com.example.myapp.dev

Run it once per environment, then select the right options at startup based on the compiled-in flavor:

Future<void> initFirebase() async {  final options = switch (AppConfig.env) {    Env.dev => DefaultFirebaseOptionsDev.currentPlatform,    Env.staging => DefaultFirebaseOptionsStaging.currentPlatform,    Env.prod => DefaultFirebaseOptionsProd.currentPlatform,  };  await Firebase.initializeApp(options: options);}

Yes, that's a switch on environment — but it's over compile-time constants, and it's the one seam where the mapping is allowed to live. Everything downstream just uses the initialized app. The prod Firebase config is genuinely absent from the dev and staging binaries, because the tree-shaker drops the branches you never take once the flavor is fixed at build time.

If you keep the native platform config files instead of generating Dart, place them under android/app/src/<flavor>/google-services.json and use a per-configuration path (or a build-phase copy script) on iOS, so Gradle and Xcode pick up the correct one automatically. Either way, the hard rule stands: never a manual copy step. Manual copies are exactly how prod credentials end up baked into a staging build.

Wiring Flutter flavors into CI/CD so the right build ships automatically

The whole point is that a human never picks the environment. CI derives it — from the branch or the tag. Here's the shape in GitHub Actions:

jobs:  build-staging:    if: github.ref == 'refs/heads/develop'    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: subosito/flutter-action@v2      - name: Build staging        run: |          flutter build appbundle \            --flavor staging \            --dart-define-from-file=config/staging.json  build-prod:    if: startsWith(github.ref, 'refs/tags/v')    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: subosito/flutter-action@v2      - name: Write prod config from secret        run: echo '${{ secrets.PROD_CONFIG_JSON }}' > config/prod.json      - name: Build prod        run: |          flutter build appbundle \            --flavor prod \            --dart-define-from-file=config/prod.json

The load-bearing decisions:

Keep the flavor and the destination tied to the same trigger. If they can drift apart, eventually they will, and you're right back to staging landing in front of real users. If you use Fastlane, Codemagic, or Bitrise instead of GitHub Actions, the mechanics change but the principle doesn't: the environment is a function of git state, computed by the machine.

Migrating off the if-check swamp one PR at a time

Most of you aren't starting fresh. You have the 300-line config.dart and the runtime flag, in production, with paying users. You don't need a rewrite — you need to strangle the old thing in order, one safe cut at a time.

I've run this migration on a live app with paying users and zero downtime by moving one key per PR. Small PRs, each independently shippable and reversible. The temptation is to do it all in one heroic branch that fixes everything at once. Resist it. That branch never merges — it grows conflicts until someone abandons it, and the swamp wins by default.

Common flavor mistakes I still see

Key takeaways