September 14, 2026
A Practical Guide to Flutter Flavors with Firebase
Set up Flutter flavors with Firebase for local, staging, and production — covering Android, iOS, FlutterFire config, emulators, and common mistakes.
Author: Norbert Aberor9 views

A Flutter flavor is a named version of the same application. Each flavor can have its own app name, icon, package identifier, API environment, and Firebase project while sharing the same Dart code.
A common setup uses three flavors:
local, for development and Firebase emulators.staging, for internal testing.production, for store releases.
This guide uses the Bricksecure app as a practical reference. Bricksecure has local, staging, and production flavors, uses FlutterFire to generate Firebase configuration, and selects the correct environment when the app starts.
Why use flavors?
Without flavors, development and production builds often share one backend. That creates avoidable risks. A test account can appear in production, a developer can send a real notification by mistake, or experimental data can affect analytics.
Flavors provide clear boundaries:
- Developers can reset local or staging data without touching production.
- Test builds can have a different name and icon.
- Several app variants can be installed on one device when they use unique identifiers.
- Crash reporting and analytics can identify the active environment.
- CI can build a specific environment with an explicit command.
A flavor is a build time choice. Runtime feature flags solve a different problem. Use flavors to select infrastructure and app identity. Use feature flags to enable or disable behavior after an app has been built.
The four layers of a complete setup
A reliable Flutter and Firebase flavor setup has four connected layers:
- Flutter knows the flavor name.
- Android defines a product flavor and application ID.
- iOS defines a scheme, build configurations, and bundle ID.
- Firebase has an app registration and configuration for each platform and environment.
The names must agree across these layers. If Flutter runs staging but Android only defines stage, the build will fail. Keeping one naming convention prevents many problems.
1. Choose the environments and identifiers
Write down the matrix before changing code. Here is a simplified version of the Bricksecure setup.
| Flavor | Display name | Android application ID | iOS bundle ID |
|---|---|---|---|
local | Bricksecure (Local) | com.example.bricksecure.local | com.example.bricksecure.local |
staging | Bricksecure (Staging) | com.example.bricksecure.staging | com.example.bricksecure.staging |
production | Bricksecure | com.example.bricksecure | com.example.bricksecure |
Unique IDs let all three variants exist on the same device. They also make accidental cross environment Firebase configuration less likely.
The current Bricksecure project intentionally shares an ID and Firebase project between some nonproduction variants. That is valid when local and staging are not meant to be installed together, but separate IDs and separate Firebase projects provide stronger isolation.
For most teams, create one Firebase project per deployed environment:
bricksecure-localbricksecure-stagingbricksecure-production
Local development can also use the Firebase Emulator Suite. In that case, the local build still needs valid Firebase initialization, but Auth, Firestore, and other supported services can point to local emulators.
2. Define the Flutter flavors
You can configure Android and iOS manually, or use a generator such as flutter_flavorizr. Bricksecure uses flutter_flavorizr to keep the platform setup consistent.
Add the development dependency:
dev_dependencies:
flutter_flavorizr: ^2.3.1
Create flavorizr.yaml in the project root:
flavors:
local:
app:
name: "Bricksecure (Local)"
android:
applicationId: "com.example.bricksecure.local"
ios:
bundleId: "com.example.bricksecure.local"
staging:
app:
name: "Bricksecure (Staging)"
android:
applicationId: "com.example.bricksecure.staging"
ios:
bundleId: "com.example.bricksecure.staging"
production:
app:
name: "Bricksecure"
android:
applicationId: "com.example.bricksecure"
ios:
bundleId: "com.example.bricksecure"
Then generate the platform configuration:
dart run flutter_flavorizr
Review the generated changes before committing them. A generator saves time, but the resulting Android and iOS files are still the source of truth for each platform.
You may also set a default flavor in pubspec.yaml:
flutter:
default-flavor: staging
This is convenient for normal development. Release automation should still pass --flavor explicitly so that the selected environment is never ambiguous.
3. Understand the Android configuration
On Android, flavors are defined in android/app/build.gradle. A minimal Groovy configuration looks like this:
android {
flavorDimensions "environment"
productFlavors {
local {
dimension "environment"
applicationId "com.example.bricksecure.local"
resValue "string", "app_name", "Bricksecure (Local)"
}
staging {
dimension "environment"
applicationId "com.example.bricksecure.staging"
resValue "string", "app_name", "Bricksecure (Staging)"
}
production {
dimension "environment"
applicationId "com.example.bricksecure"
resValue "string", "app_name", "Bricksecure"
}
}
}
Each product flavor combines with build types such as debug and release. Android therefore creates variants such as localDebug, stagingRelease, and productionRelease.
If all IDs share a base, applicationIdSuffix can reduce repetition:
local {
dimension "environment"
applicationIdSuffix ".local"
}
This only works cleanly when every flavor follows the same identifier pattern.
Enable the Google Services plugin
FlutterFire normally adds the plugin. Verify that the project level plugin declaration exists in android/settings.gradle or android/settings.gradle.kts:
plugins {
id "com.google.gms.google-services" version "4.4.2" apply false
}
Apply it in android/app/build.gradle:
plugins {
id "com.android.application"
id "com.google.gms.google-services"
id "dev.flutter.flutter-gradle-plugin"
}
Android automatically reads Firebase files from flavor source sets:
android/app/src/local/google-services.json
android/app/src/staging/google-services.json
android/app/src/production/google-services.json
Use lowercase directory names that exactly match the flavor names. Case differences can behave differently across file systems and CI machines.
4. Understand the iOS configuration
iOS uses schemes and build configurations instead of Android product flavors. A complete staging setup normally includes:
- A shared
stagingscheme. Debug-staging,Profile-staging, andRelease-stagingbuild configurations.- A staging bundle identifier.
- Flavor specific values for the display name and assets.
The generated schemes should be stored under:
ios/Runner.xcodeproj/xcshareddata/xcschemes/
Commit shared schemes to Git. A scheme that only exists in one developer's Xcode user data will not be available to teammates or CI.
Small .xcconfig files make flavor values easier to inspect. For example:
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
BUNDLE_DISPLAY_NAME=Bricksecure (Staging)
ios/Runner/Info.plist can then reference the setting:
<key>CFBundleDisplayName</key>
<string>$(BUNDLE_DISPLAY_NAME)</string>
If you configure iOS manually, open ios/Runner.xcworkspace, duplicate the Debug, Profile, and Release configurations for every flavor, then create one shared scheme per flavor. Ensure each scheme maps Run, Profile, and Archive to its matching build configuration.
The generator approach is much less error prone for the initial setup. Manual Xcode knowledge is still useful when diagnosing signing, CocoaPods, or archive issues.
5. Install Firebase and FlutterFire
Install the Firebase CLI:
npm install -g firebase-tools
firebase login
firebase --version
Install the FlutterFire CLI:
dart pub global activate flutterfire_cli
flutterfire --version
Add Firebase Core to the Flutter project:
flutter pub add firebase_core
Use the Google account that has access to all required Firebase projects. If project discovery fails, confirm the active account with the Firebase CLI before changing the app configuration.
6. Generate Firebase configuration for every flavor
Running flutterfire configure without output paths is suitable for a single environment. With flavors, give each generated file a distinct destination.
Here is a staging example:
flutterfire configure \
--project=bricksecure-staging \
--out=lib/firebase_options_staging.dart \
--ios-bundle-id=com.example.bricksecure.staging \
--ios-out=ios/flavors/staging/GoogleService-Info.plist \
--android-package-name=com.example.bricksecure.staging \
--android-out=android/app/src/staging/google-services.json
The important arguments are:
--project, the Firebase project ID, not its display name or local alias.--out, the generated Dart options file.--ios-bundle-id, the exact iOS bundle ID registered with Firebase.--ios-out, the destination of the iOS plist.--android-package-name, the exact Android application ID.--android-out, the flavor source set destination.
Repeat this for each flavor. The final structure should be similar to:
lib/firebase_options_local.dart
lib/firebase_options_staging.dart
lib/firebase_options_production.dart
ios/flavors/local/GoogleService-Info.plist
ios/flavors/staging/GoogleService-Info.plist
ios/flavors/production/GoogleService-Info.plist
android/app/src/local/google-services.json
android/app/src/staging/google-services.json
android/app/src/production/google-services.json
Automate the commands
A short script keeps package names, bundle IDs, and output paths in one place:
#!/usr/bin/env bash
set -euo pipefail
flavor="${1:-}"
case "$flavor" in
staging)
flutterfire configure \
--project=bricksecure-staging \
--out=lib/firebase_options_staging.dart \
--ios-bundle-id=com.example.bricksecure.staging \
--ios-out=ios/flavors/staging/GoogleService-Info.plist \
--android-package-name=com.example.bricksecure.staging \
--android-out=android/app/src/staging/google-services.json
;;
production)
flutterfire configure \
--project=bricksecure-production \
--out=lib/firebase_options_production.dart \
--ios-bundle-id=com.example.bricksecure \
--ios-out=ios/flavors/production/GoogleService-Info.plist \
--android-package-name=com.example.bricksecure \
--android-out=android/app/src/production/google-services.json
;;
*)
echo "Usage: $0 staging|production"
exit 1
;;
esac
Run it from the project root:
chmod +x flutterfire-config.sh
./flutterfire-config.sh staging
Add the local branch in the same form if local development uses a Firebase project. When it uses emulators, generate valid local options first, then connect supported Firebase services to emulator hosts during startup.
7. Make the iOS plist match the selected scheme
Android selects google-services.json from the source set automatically. iOS needs each selected plist to become part of the app bundle.
One approach is an Xcode Run Script build phase that copies the file for the current build configuration:
case "$CONFIGURATION" in
*-local)
cp "$PROJECT_DIR/flavors/local/GoogleService-Info.plist" \
"$PROJECT_DIR/Runner/GoogleService-Info.plist"
;;
*-staging)
cp "$PROJECT_DIR/flavors/staging/GoogleService-Info.plist" \
"$PROJECT_DIR/Runner/GoogleService-Info.plist"
;;
*-production)
cp "$PROJECT_DIR/flavors/production/GoogleService-Info.plist" \
"$PROJECT_DIR/Runner/GoogleService-Info.plist"
;;
esac
Add this phase before Copy Bundle Resources. The input paths and output paths should match the source and copied plist files so Xcode understands when the script must run.
There are other valid iOS layouts. For example, each configuration can reference a different plist directly. Whichever method you choose, use one source of truth. A frequent mistake is generating files into ios/flavors/... while the build script copies from ios/Runner/....
8. Select the Firebase options in Dart
Flutter exposes the selected flavor through appFlavor. A concise startup function can choose the corresponding generated options:
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/services.dart';
import 'firebase_options_local.dart' as local;
import 'firebase_options_staging.dart' as staging;
import 'firebase_options_production.dart' as production;
Future<void> initializeFirebase() async {
final options = switch (appFlavor) {
'local' => local.DefaultFirebaseOptions.currentPlatform,
'staging' => staging.DefaultFirebaseOptions.currentPlatform,
'production' => production.DefaultFirebaseOptions.currentPlatform,
_ => throw UnsupportedError('Unknown flavor: $appFlavor'),
};
await Firebase.initializeApp(options: options);
}
Call it after Flutter binding initialization:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeFirebase();
runApp(const App());
}
This is close to the approach used by Bricksecure. Its Flavor enum also provides flavor specific display values:
enum Flavor { local, staging, production }
String titleFor(Flavor flavor) => switch (flavor) {
Flavor.local => 'Bricksecure (Local)',
Flavor.staging => 'Bricksecure (Staging)',
Flavor.production => 'Bricksecure',
};
A stricter option, separate entry points
The centralized function imports every generated Firebase options file. Those values can all be included in the compiled application.
Firebase client configuration values are identifiers, not server secrets. Security must come from Firebase Security Rules, App Check, authentication, and protected server credentials. Even so, some teams prefer each binary to include only its own environment configuration.
Separate entry points provide that isolation:
// lib/main_staging.dart
import 'firebase_options_staging.dart';
import 'main.dart';
Future<void> main() async {
await runMain(DefaultFirebaseOptions.currentPlatform);
}
The shared entry point accepts the selected options:
Future<void> runMain(FirebaseOptions options) async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: options);
runApp(const App());
}
Run it with both the native flavor and Dart target:
flutter run --flavor staging -t lib/main_staging.dart
Use one entry point when simplicity is the priority. Use separate entry points when build isolation, tree shaking, or per environment startup behavior matters.
9. Connect the local flavor to Firebase emulators
For local development, initialize Firebase first, then connect each service:
Future<void> connectToEmulators() async {
if (appFlavor != 'local') return;
final host = defaultTargetPlatform == TargetPlatform.android
? '10.0.2.2'
: 'localhost';
await FirebaseAuth.instance.useAuthEmulator(host, 9099);
FirebaseFirestore.instance.useFirestoreEmulator(host, 8080);
}
Android emulators use 10.0.2.2 to reach the host computer. iOS simulators and desktop platforms usually use localhost. A physical device needs the development computer's network address and suitable firewall access.
Keep the flavor check strict. A production build must never connect to local emulators.
10. Run and build each flavor
With one Dart entry point:
flutter run --flavor local
flutter run --flavor staging
flutter run --flavor production
With separate entry points:
flutter run --flavor local -t lib/main_local.dart
flutter run --flavor staging -t lib/main_staging.dart
flutter run --flavor production -t lib/main_production.dart
Release builds follow the same pattern:
flutter build appbundle --release --flavor production
flutter build ipa --release --flavor production
Add IDE launch configurations so developers do not need to remember the arguments:
{
"name": "Bricksecure, Staging",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": ["--flavor", "staging"]
}
For separate entry points, change program to lib/main_staging.dart.
11. Decide how to store Firebase configuration
Files such as google-services.json, GoogleService-Info.plist, and generated firebase_options_*.dart contain Firebase client configuration. They do not contain a Firebase Admin private key.
It is normally acceptable to commit them in a private application repository. They must not be treated as authorization. Protect data with Security Rules and protect privileged operations on trusted servers.
Some teams exclude these files from public repositories. If you do that, CI and new developers need a documented way to restore or regenerate them. Avoid a setup where release builds depend on files that exist only on one laptop.
Never commit service account JSON, signing keys, keystore passwords, .p8 keys, or server credentials.
12. Verification checklist
Test each flavor before considering the setup complete:
- Run the app and confirm its visible name and icon.
- Print the selected flavor in debug mode.
- Confirm the Android application ID or iOS bundle ID.
- Confirm
Firebase.app().options.projectId. - Create a disposable test record and verify which Firebase project receives it.
- Confirm the local flavor reaches emulators.
- Build a release artifact, not only a debug build.
- Archive each iOS scheme and verify signing.
- Run the same commands in CI on a clean checkout.
A useful temporary assertion is:
assert(
Firebase.app().options.projectId == expectedProjectId,
'The selected flavor is connected to the wrong Firebase project.',
);
Do not rely only on app names. Package identifiers and Firebase project IDs are stronger evidence.
Common mistakes
The Firebase package name does not match the flavor
The Android application ID and iOS bundle ID passed to FlutterFire must exactly match the native flavor. Registering com.example.bricksecure while building com.example.bricksecure.staging creates a configuration mismatch.
Folder names use different capitalization
Staging and staging may appear equivalent on a local machine but fail on a case sensitive CI runner. Match flavor names exactly and use lowercase names consistently.
The iOS copy script and generated output disagree
If FlutterFire writes to ios/flavors/staging but Xcode copies from ios/Runner/staging, the build can package an old file or fail. Define the path once and use it everywhere.
Profile configurations are omitted
Create Debug, Profile, and Release configurations for each iOS scheme. Tooling and performance testing may use Profile even when daily development does not.
Local and staging share one identifier
This prevents side by side installation and can make push notification or deep link testing confusing. Sharing can be intentional, but unique identifiers are a safer default.
Firebase configuration is mistaken for security
Hiding client configuration does not secure Firebase. Write and test Security Rules, enable App Check where appropriate, restrict API keys by platform where supported, and keep Admin SDK credentials on trusted infrastructure.
CI silently uses the default flavor
Always include --flavor in release commands. If using separate Dart files, also include -t. Explicit commands make deployment intent reviewable.
Final project structure
A clean flavor setup can look like this:
android/app/src/
local/google-services.json
staging/google-services.json
production/google-services.json
ios/
flavors/
local/GoogleService-Info.plist
staging/GoogleService-Info.plist
production/GoogleService-Info.plist
Runner.xcodeproj/xcshareddata/xcschemes/
local.xcscheme
staging.xcscheme
production.xcscheme
lib/
flavors.dart
firebase_options_local.dart
firebase_options_staging.dart
firebase_options_production.dart
main.dart
flavorizr.yaml
flutterfire-config.sh
The exact filenames can differ. Consistency matters more than a particular naming style.
Conclusion
Flutter flavors are most reliable when treated as a complete environment contract. The native app identity, Flutter flavor name, Firebase registration, generated configuration, and build command must all describe the same environment.
Start with a small environment matrix, generate the platform setup, configure Firebase once per flavor, and verify each build against the expected Firebase project. Automate repeated FlutterFire commands, keep release commands explicit, and test from a clean checkout.
With that foundation, local testing stays safe, staging becomes predictable, and production releases are much harder to misconfigure.


