← All posts

7 min read

Flutter Flavors: Setting Up Dev, Staging and Production Builds

A practical walkthrough of Flutter flavors: per-environment config with --dart-define-from-file, Android productFlavors, iOS schemes, separate Firebase projects and CI.

  • Flutter
  • Flavors
  • Build Configuration
Cover illustration for Flutter Flavors: Setting Up Dev, Staging and Production Builds

At some point every app without environments has the same bad afternoon. Someone tests a “delete account” flow against what they believe is a test server, and it isn’t. Or a push notification meant for QA lands on real users’ phones.

Flavors fix this. With flavors you build the same codebase into separate apps: one for development, one for staging, one for production. Each has its own app ID, name, icon, API URL and Firebase project. They install side by side on one phone, and you can tell at a glance which one you’re tapping.

This post is the setup I use, step by step.

Why separate environments at all

Three reasons, in order of how much pain they prevent:

  1. Data safety. Dev and staging builds should never be able to touch production data. Not “shouldn’t by convention”. They should be unable to, because they hold different URLs and credentials.
  2. Side-by-side installs. QA testers want the store version and the latest test build on the same device. That only works if the two have different application IDs.
  3. Honest testing. Analytics, crash reports and push notifications from test builds pollute production dashboards. Separate projects keep production numbers meaningful.

A typical setup is three environments: dev for daily work against a local or dev backend, staging that mirrors production for QA and client review, and prod. Some teams skip staging. I wouldn’t skip it for any app that takes payments.

Step 1: Environment config with –dart-define-from-file

First, get environment values into Dart. I keep one JSON file per environment:

{
  "ENV": "dev",
  "API_BASE_URL": "https://api-dev.example.com",
  "SENTRY_ENABLED": "false"
}

Save them as config/dev.json, config/staging.json and config/prod.json, then pass one at build time:

flutter run --dart-define-from-file=config/dev.json

In Dart, read the values as compile-time constants. They have to be const. String.fromEnvironment doesn’t work as a regular runtime call.

class Env {
  static const name = String.fromEnvironment('ENV', defaultValue: 'dev');
  static const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
  static const sentryEnabled =
      bool.fromEnvironment('SENTRY_ENABLED', defaultValue: false);

  static bool get isProd => name == 'prod';
}

Two rules I follow. First, these files are not secret storage. Anything compiled into the app can be pulled out of the binary. API base URLs and feature switches are fine here. Server secrets never belong in a mobile app at all (more on that in my mobile app security checklist). Second, I fail fast. If API_BASE_URL is empty, the app throws at startup in debug mode. A missing define should be a loud error on day one, not a confusing network failure on day ten.

Step 2: Android productFlavors

Dart config alone gives you one app that points at different servers. To get separate apps, you need native flavors. On Android they go in android/app/build.gradle.kts, inside the android { } block:

flavorDimensions += "environment"

productFlavors {
    create("dev") {
        dimension = "environment"
        applicationIdSuffix = ".dev"
        resValue("string", "app_name", "MyApp Dev")
    }
    create("staging") {
        dimension = "environment"
        applicationIdSuffix = ".staging"
        resValue("string", "app_name", "MyApp Staging")
    }
    create("prod") {
        dimension = "environment"
        resValue("string", "app_name", "MyApp")
    }
}

applicationIdSuffix turns com.example.myapp into com.example.myapp.dev, so Android treats it as a different app. Production gets no suffix, so it keeps the ID it was published with. Changing a live app’s ID would make it a brand-new app on Google Play.

For the name, point the manifest’s label at the generated string:

<application
    android:label="@string/app_name"
    android:icon="@mipmap/ic_launcher">

If your project still uses the Groovy build.gradle, the same ideas apply with slightly different syntax (dev { ... } instead of create("dev")).

Step 3: iOS schemes and build configurations

iOS is where flavor setups usually go wrong, so I’ll go slowly. There’s no productFlavors block. You build the same thing out of three Xcode concepts:

  • Build configurations. Out of the box a Flutter iOS project has Debug, Release and Profile. For flavors you duplicate each one per environment and name them Debug-dev, Release-dev, Profile-dev, Debug-staging, and so on. Flutter expects the configuration name to end in -<flavor>, so the naming matters.
  • Schemes. Create one scheme per flavor, named exactly like the flavor (dev, staging, prod). Edit each scheme so its Run, Test, Profile and Archive actions use that flavor’s configurations. Mark the schemes as shared so they’re committed to git and CI can see them.
  • xcconfig files. Instead of clicking through build settings for every configuration, I create a small .xcconfig file per environment. It sets values like the bundle identifier and display name, and I attach it to the matching configurations. Each one also needs to include the CocoaPods-generated config and Flutter’s Generated.xcconfig, the same way the default Debug and Release files do.

Then, in the target’s build settings, set Product Bundle Identifier and the display name (CFBundleDisplayName in Info.plist) from variables that differ per configuration. The result mirrors Android: com.example.myapp.dev named “MyApp Dev”, next to the production app.

It’s tedious the first time. Do it once, carefully, and commit it. If CocoaPods complains about unknown configurations, the Podfile’s project mapping usually needs to list the new configuration names as debug or release.

Step 4: Running a flavor

With both platforms configured, one command selects everything:

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

--flavor picks the native flavor (Android product flavor, iOS scheme). --dart-define-from-file picks the Dart config. Keeping them in sync by hand is error-prone, so I put these commands in a Makefile or a small script and add IDE launch configurations for each flavor. Nobody should be typing them from memory.

Flutter also exposes the current flavor to Dart through appFlavor in package:flutter/services.dart. I still prefer an explicit ENV value from the JSON file, because it works the same way in tests and in tooling that doesn’t pass --flavor.

Step 5: One Firebase project per flavor

If the app uses Firebase, give each environment its own Firebase project. Then test users, Firestore data, crash reports and analytics stay separate. It also means you can’t accidentally send a test push to production users.

The FlutterFire CLI can generate a config per environment. Run it once per flavor, pointing at the matching project and app IDs:

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

Repeat for staging and prod, then pick the right options at startup:

final options = switch (Env.name) {
  'prod' => prod.DefaultFirebaseOptions.currentPlatform,
  'staging' => staging.DefaultFirebaseOptions.currentPlatform,
  _ => dev.DefaultFirebaseOptions.currentPlatform,
};
await Firebase.initializeApp(options: options);

The native config files need attention too. On Android, google-services.json can live in a flavor-specific folder (android/app/src/dev/), and the Google Services Gradle plugin picks up the right one. On iOS, each flavor needs its own GoogleService-Info.plist copied in at build time. The FlutterFire CLI has options to write these per flavor and set up the copy step. Check its current docs for the exact flags, because they’ve changed between versions. I compare Firebase to the alternatives in Firebase vs Supabase.

Step 6: Different icons per flavor

This is the cheapest improvement on the list, and testers love it. Give dev and staging builds a visibly different icon: a colored band, a “DEV” badge, anything.

On Android, flavor-specific resources go in android/app/src/dev/res/mipmap-* and override the main ones automatically. On iOS, add an app icon set per flavor in the asset catalog and set the Primary App Icon Set Name build setting per configuration. The flutter_launcher_icons package can generate both from one config file per flavor, which saves you from resizing PNGs by hand.

Step 7: Build every flavor in CI

Flavors make CI pipelines easy to read. A pipeline builds staging on every merge to the main branch and ships it to testers. A tag or release branch builds prod and sends it to the stores. Each job passes its own --flavor and --dart-define-from-file values, and signing credentials are injected per environment from the CI secret store.

I walk through a full pipeline in Flutter CI/CD with Azure DevOps. The flavor work in this post is what keeps that pipeline simple. Without it you end up with scripts that sed-replace URLs before each build, and sooner or later one of those replacements fails without anyone noticing.

Takeaways

  • Use flavors so dev and staging builds can’t reach production, not just “shouldn’t”.
  • Put per-environment values in JSON files and load them with --dart-define-from-file. Never put secrets in them.
  • Android: productFlavors with applicationIdSuffix and a per-flavor app name. Leave the prod ID untouched.
  • iOS: per-flavor build configurations named *-<flavor>, one shared scheme per flavor, and xcconfig files for bundle ID and name.
  • Use a separate Firebase project and a distinct icon for each environment.
  • Script the build commands and let CI build each flavor, so nobody has to remember the flags.

Flavors take an afternoon to set up, and they keep test traffic out of production for the rest of the project.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).