Flutter Flavors Done Right: Production and Staging Configuration

A practical, end-to-end guide to configuring Flutter flavors for production and staging across Android, iOS, CI/CD, Firebase, and more.

ASOasis
7 min read
Flutter Flavors Done Right: Production and Staging Configuration

Image used for representation purposes only.

Overview

Setting up production and staging flavors in Flutter gives you clean separation between environments, URLs, app icons, analytics, and signing—without sprinkling if-else logic throughout your code. This guide walks you through a pragmatic, battle-tested configuration for Android and iOS, plus CI/CD, Firebase, and common pitfalls.

What “flavors” mean in Flutter

A flavor is an environment-specific build variant with its own:

  • Bundle/package id (e.g., com.example.app vs com.example.app.stg)
  • App name and icon (e.g., “Example” vs “Example Staging”)
  • API base URLs, feature flags, OAuth schemes
  • Analytics and Crashlytics projects
  • Signing/provisioning

Flutter doesn’t impose flavors itself; you wire them up in platform projects (Android/iOS) and expose them to Dart via entrypoints and dart-defines.

lib/
  main_production.dart
  main_staging.dart
  flavors/
    flavor.dart
    flavor_banner.dart  // optional visual ribbon overlay
  config/
    environment.dart
    config_loader.dart
assets/config/
  config_production.json
  config_staging.json
android/
ios/
fastlane/
  • Keep environment settings declarative (JSON or dart-defines).
  • Provide separate entrypoints per flavor for clarity.

Step 1 — Dart-side flavor model

Create a simple model and loader so your UI and services read from a single source of truth.

// lib/flavors/flavor.dart
enum AppFlavor { production, staging }

class FlavorValues {
  final String appName;
  final String apiBaseUrl;
  final bool enableDebugMenu;
  const FlavorValues({
    required this.appName,
    required this.apiBaseUrl,
    this.enableDebugMenu = false,
  });
}

class FlavorConfig {
  static late AppFlavor flavor;
  static late FlavorValues values;

  static void init({required AppFlavor flavor, required FlavorValues values}) {
    FlavorConfig.flavor = flavor;
    FlavorConfig.values = values;
  }
}
// lib/main_staging.dart
import 'flavors/flavor.dart';
import 'package:flutter/material.dart';

void main() {
  FlavorConfig.init(
    flavor: AppFlavor.staging,
    values: const FlavorValues(
      appName: 'Example Staging',
      apiBaseUrl: 'https://staging.api.example.com',
      enableDebugMenu: true,
    ),
  );
  runApp(const MyApp());
}
// lib/main_production.dart
import 'flavors/flavor.dart';
import 'package:flutter/material.dart';

void main() {
  FlavorConfig.init(
    flavor: AppFlavor.production,
    values: const FlavorValues(
      appName: 'Example',
      apiBaseUrl: 'https://api.example.com',
    ),
  );
  runApp(const MyApp());
}

Tip: You can also parameterize with –dart-define and parse using const String.fromEnvironment to avoid multiple entrypoints; entrypoints are just clearer for teams.

Step 2 — Android configuration

  1. Add flavors to Gradle.

Edit android/app/build.gradle:

android {
    flavorDimensions "env"
    productFlavors {
        staging {
            dimension "env"
            applicationIdSuffix ".stg"
            versionNameSuffix "-stg"
            resValue "string", "app_name", "Example Staging"
        }
        production {
            dimension "env"
            // No suffix—this is your release id
            resValue "string", "app_name", "Example"
        }
    }
}

// Keep at bottom of file
dependencies {
    // ...
}

apply plugin: 'com.google.gms.google-services' // if using Firebase

Notes:

  • applicationId production might be com.example.app; staging becomes com.example.app.stg via suffix.
  • resValue overrides app_name per flavor.
  1. Icons per flavor (optional but recommended).

Use flutter_launcher_icons with flavors in pubspec.yaml:

flutter_launcher_icons:
  android: true
  ios: true
  flavors:
    staging:
      image_path: assets/icons/icon_staging.png
      android: { adaptive_icon_background: '#F0B90B', adaptive_icon_foreground: assets/icons/icon_staging.png }
      ios: { image_path: assets/icons/icon_staging.png }
    production:
      image_path: assets/icons/icon_prod.png

Run:

flutter pub run flutter_launcher_icons:main -f pubspec.yaml
  1. Firebase per flavor.
  • Place google-services.json in:
    • android/app/src/staging/
    • android/app/src/production/
  • The Google Services plugin auto-picks the matching file by flavor.
  1. Manifest overrides.

Add per-flavor AndroidManifest.xml in android/app/src/staging and android/app/src/production if you need different deep links, intent filters, or OAuth redirect schemes.

Step 3 — iOS configuration

  1. Add schemes and configurations in Xcode.
  • Duplicate the Debug and Release configurations into Staging and Production (e.g., Debug-Staging, Release-Production) or keep Debug/Release but tie them to schemes.
  • Create two schemes: “App Staging” and “App Production”, each mapping to the correct build configuration.
  1. Bundle identifiers.
  • Set PRODUCT_BUNDLE_IDENTIFIER per configuration:
    • Production: com.example.app
    • Staging: com.example.app.stg
  1. App name and icons.
  • Set CFBundleDisplayName with a build setting per configuration (e.g., Example vs Example Staging).
  • For icons, either manage two Asset Catalogs (AppIcon-Prod, AppIcon-Stg) and select via “Asset Catalog App Icon Set Name” per configuration, or use flutter_launcher_icons flavors for iOS.
  1. Firebase per flavor.
  • Add GoogleService-Info.plist twice and name-targeted:
    • ios/Runner/Firebase/Production/GoogleService-Info.plist
    • ios/Runner/Firebase/Staging/GoogleService-Info.plist
  • In Xcode, add a “Run Script” or “Copy Files” Build Phase in each configuration that copies the correct plist into the bundle as GoogleService-Info.plist. Alternatively, use a per-configuration path in the Firebase initialization if you manage it manually.
  1. URL types and deep links.
  • Add a distinct URL scheme per flavor (e.g., example.prod:// and example.stg://) in Info > URL Types.
  • If using OAuth (e.g., Google/Firebase/Auth0), register separate redirect URIs and iOS bundle ids for each flavor.

Step 4 — Assets and config files

Sometimes you want additional per-flavor settings without recompiling native code.

  • Create assets/config/config_production.json and config_staging.json.
  • Load them based on FlavorConfig at startup.
// lib/config/config_loader.dart
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
import '../flavors/flavor.dart';

class AppConfig {
  final String sentryDsn;
  final String featureToggleServiceUrl;
  AppConfig({required this.sentryDsn, required this.featureToggleServiceUrl});
}

Future<AppConfig> loadAppConfig() async {
  final file = FlavorConfig.flavor == AppFlavor.production
      ? 'assets/config/config_production.json'
      : 'assets/config/config_staging.json';
  final jsonStr = await rootBundle.loadString(file);
  final data = json.decode(jsonStr) as Map<String, dynamic>;
  return AppConfig(
    sentryDsn: data['sentryDsn'] as String,
    featureToggleServiceUrl: data['featureToggleServiceUrl'] as String,
  );
}

Register the assets in pubspec.yaml:

flutter:
  assets:
    - assets/config/config_production.json
    - assets/config/config_staging.json

Step 5 — Build and run commands

  • Android staging debug:
flutter run --flavor staging -t lib/main_staging.dart
  • Android production release (APK/AAB):
flutter build appbundle --flavor production -t lib/main_production.dart
  • iOS staging:
flutter run --flavor staging -t lib/main_staging.dart
  • iOS production archive:
flutter build ipa --flavor production -t lib/main_production.dart

Alternative: Single entrypoint with dart-defines.

flutter run -t lib/main.dart \
  --dart-define=FLUTTER_FLAVOR=staging \
  --dart-define=API_BASE_URL=https://staging.api.example.com

Then read with const String.fromEnvironment in Dart.

CI/CD examples

GitHub Actions (Android AAB):

name: Android Release
on:
  workflow_dispatch:
  push:
    tags: [ 'v*' ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with: { flutter-version: '3.22.0' }
      - run: flutter pub get
      - run: flutter build appbundle --flavor production -t lib/main_production.dart
      - uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.app
          releaseFiles: build/app/outputs/bundle/productionRelease/app-production-release.aab
          track: production

Fastlane lanes (iOS):

lane :ios_staging do
  build_ios_app(
    scheme: "App Staging",
    export_method: "ad-hoc",
    output_directory: "build/ios_staging"
  )
end

lane :ios_production do
  build_ios_app(
    scheme: "App Production",
    export_method: "app-store",
    output_directory: "build/ios_prod"
  )
  upload_to_app_store(
    skip_metadata: true,
    skip_screenshots: true
  )
end

Pro tips:

  • Inject API keys/secrets via CI environment variables; don’t commit them.
  • Map Git branches to tracks: main -> production, develop -> internal/staging.

Firebase, analytics, and crashes per flavor

  • Use separate Firebase projects to avoid polluting production analytics.
  • Configure Crashlytics and Analytics with the right plist/json per flavor. Verify after first launches by checking the correct project.
  • If using Sentry, set environment=staging or production via SDK init from FlavorConfig.
// Example Sentry init
await SentryFlutter.init((options) {
  options.dsn = appConfig.sentryDsn;
  options.environment = FlavorConfig.flavor.name; // 'staging' or 'production'
});
  • Provide distinct redirect URIs and URL schemes by flavor.
  • Android: intent-filters per flavor manifest and unique host/path (e.g., prod.example.com vs stg.example.com).
  • iOS: URL Types per configuration; ensure associated domains are provisioned separately for Universal Links.

Signing and distribution

  • Android: Use a single keystore for both or separate ones for isolation. Configure signingConfigs per buildType and flavor.
  • iOS: Create separate App IDs, provisioning profiles, and APNs keys for staging and production. Map them to schemes.
  • Stores: Use Google Play internal/alpha tracks for staging. On App Store Connect, use TestFlight for staging builds.

Common pitfalls (and fixes)

  • Wrong Firebase project: Confirm the flavor banner in-app and check Firebase DebugView or Crashlytics keys.
  • Mixed bundle ids: On iOS, archived scheme must match the configuration’s PRODUCT_BUNDLE_IDENTIFIER.
  • Push notifications fail on staging: Ensure separate APNs certificates/keys and that your push provider targets the staging bundle id.
  • Deep links open the wrong app: Use distinct schemes/hosts and verify app association files per environment.
  • CI uses release API URL for staging: Lock API base via dart-defines or assets; do not share .env files between jobs.
  • App name/icon unchanged: Clean derived data (Xcode) or run flutter clean; verify the selected icon set per configuration.

Beyond mobile: web and desktop

  • Web doesn’t support “flavors” like Android/iOS, but you can use build-time dart-defines and multiple index.html files.
  • Example: flutter build web –dart-define=FLUTTER_FLAVOR=staging and switch base href or API URL accordingly.
  • For macOS/Windows, you can mimic flavors with build configurations and bundle identifiers, but tooling is less standardized.

Quick checklist

  • Separate entrypoints or dart-defines
  • Android productFlavors with applicationIdSuffix and app_name
  • iOS schemes/configurations with bundle ids and icons
  • Distinct Firebase projects and config files
  • Deep links and OAuth redirect URIs per flavor
  • CI lanes/Actions per flavor with proper signing
  • Visual flavor banner to avoid mistakes in QA

Example flavor banner (optional but effective)

// lib/flavors/flavor_banner.dart
import 'package:flutter/material.dart';
import 'flavor.dart';

class FlavorBanner extends StatelessWidget {
  final Widget child;
  const FlavorBanner({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    if (FlavorConfig.flavor == AppFlavor.production) return child;
    return Stack(children: [
      child,
      Positioned(
        left: 0,
        top: 24,
        child: Transform.rotate(
          angle: -0.785,
          child: Container(
            color: Colors.orange,
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
            child: const Text('STAGING', style: TextStyle(color: Colors.white)),
          ),
        ),
      ),
    ]);
  }
}

Wrap your MaterialApp with FlavorBanner in staging entrypoint only.

Conclusion

A robust flavor setup isolates environments, reduces costly mistakes, and streamlines releases. With clear Dart entrypoints, Android/iOS flavor wiring, per-environment services (Firebase, analytics, push), and CI lanes, your team can ship confidently to staging and production without compromises.

Related Posts