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.
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.
Recommended project structure
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
- 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.
- 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
- 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.
- 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
- 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.
- Bundle identifiers.
- Set PRODUCT_BUNDLE_IDENTIFIER per configuration:
- Production: com.example.app
- Staging: com.example.app.stg
- 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.
- 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.
- 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'
});
Deep links and OAuth
- 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
Migrating to Dart 3 Null Safety in Flutter: A Practical Guide
Step-by-step guide to migrate a Flutter codebase to Dart 3’s sound null safety with commands, patterns, pitfalls, and checklists.
Mastering Flutter Platform Channels on iOS and Android: A Practical Guide
A practical, end-to-end guide to Flutter platform channels on iOS and Android with Kotlin, Swift, Dart code, testing, performance, and pitfalls.
Flutter Firebase Authentication: A Complete, Modern Guide
A complete, pragmatic guide to Firebase Authentication in Flutter: setup, email, Google, Apple, phone, linking, security, and testing.