Managing Firebase per flavor in Flutter
By Charlin Joe · 12 September 2024
Every Flutter app that uses Firebase needs a google-services.json (Android) and GoogleService-Info.plist (iOS). The problem: these files contain API keys and project identifiers that differ between dev, staging, and production. Committing all of them to the repo works but leaks sensitive data. Here is how to manage it cleanly.
The core problem
You have three Firebase projects (one per environment) and need the correct config file in the right place at build time. The files can't live in the repo (sensitive) but need to be on disk when Flutter builds. On developer machines they're usually copied manually. In CI that breaks immediately.
Option 1: File per flavor using Gradle and Xcode scripts
Create a directory structure per flavor:
android/app/
├── src/
│ ├── dev/google-services.json
│ ├── staging/google-services.json
│ └── production/google-services.json
Gradle automatically picks up the file from the matching flavor source set — no script needed if you name folders to match flavor names exactly. The google-services plugin resolves src/{flavorName}/google-services.json before src/main.
For iOS it's manual. Add a Run Script build phase in Xcode that copies the right plist:
# Runs before Compile Sources
CONFIG_FILE="${PROJECT_DIR}/config/${FLUTTER_FLAVOR}/GoogleService-Info.plist"
cp -f "$CONFIG_FILE" "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist"
Store the config files in a directory that's gitignored and provision them in CI via secrets.
Option 2: annspec.yaml + ann_flutter_flavor (recommended)
If you're using ann_flutter_flavor, the sync command handles Firebase config placement automatically. You store the Firebase config files in your secrets manager and the tool places them correctly at build time:
# annspec.yaml
flavors:
dev:
appName: "MyApp Dev"
bundleId: com.example.myapp.dev
firebase:
androidAppId: "1:123:android:abc"
iosAppId: "1:123:ios:abc"
production:
appName: "MyApp"
bundleId: com.example.myapp
firebase:
androidAppId: "1:456:android:def"
iosAppId: "1:456:ios:def"
Run dart run ann_flutter_flavor sync to generate the Firebase initialization scripts that pick the right project at runtime.
Option 3: Runtime initialization (no config files)
Instead of google-services.json, initialize Firebase manually with values from --dart-define:
// lib/firebase_options_dev.dart
const firebaseOptionsDev = FirebaseOptions(
apiKey: String.fromEnvironment('FIREBASE_API_KEY_DEV'),
appId: String.fromEnvironment('FIREBASE_APP_ID_DEV'),
messagingSenderId: String.fromEnvironment('FIREBASE_SENDER_ID_DEV'),
projectId: String.fromEnvironment('FIREBASE_PROJECT_ID_DEV'),
storageBucket: String.fromEnvironment('FIREBASE_BUCKET_DEV'),
);
void main() async {
WidgetsFlutterBinding.ensureInitialized();
const flavor = String.fromEnvironment('FLAVOR', defaultValue: 'production');
final options = flavor == 'dev' ? firebaseOptionsDev : firebaseOptionsProd;
await Firebase.initializeApp(options: options);
runApp(const MyApp());
}
Build with:
flutter run --flavor dev \
--dart-define=FLAVOR=dev \
--dart-define=FIREBASE_API_KEY_DEV=AIzaSy... \
--dart-define=FIREBASE_APP_ID_DEV=1:123:android:abc
Advantage: no files on disk, values come from environment variables in CI. Disadvantage: some Firebase SDKs (Crashlytics, Performance) need the native config files regardless.
CI setup with GitHub Actions
Store each config file as a base64-encoded secret:
# One-time: encode the file
base64 -i android/app/src/dev/google-services.json | pbcopy
# Paste into GitHub secret: GOOGLE_SERVICES_DEV
Decode in the workflow:
- name: Decode Firebase configs
run: |
echo "$GOOGLE_SERVICES_DEV" | base64 --decode \
> android/app/src/dev/google-services.json
echo "$GOOGLE_SERVICES_PROD" | base64 --decode \
> android/app/src/production/google-services.json
echo "$GOOGLE_PLIST_DEV" | base64 --decode \
> ios/config/dev/GoogleService-Info.plist
echo "$GOOGLE_PLIST_PROD" | base64 --decode \
> ios/config/production/GoogleService-Info.plist
env:
GOOGLE_SERVICES_DEV: ${{ secrets.GOOGLE_SERVICES_DEV }}
GOOGLE_SERVICES_PROD: ${{ secrets.GOOGLE_SERVICES_PROD }}
GOOGLE_PLIST_DEV: ${{ secrets.GOOGLE_PLIST_DEV }}
GOOGLE_PLIST_PROD: ${{ secrets.GOOGLE_PLIST_PROD }}
Selecting the Firebase project at runtime
Once initialization is correct, you can scope Firestore, Auth, and other services to the right project:
final app = Firebase.app(); // The initialized app
final db = FirebaseFirestore.instanceFor(app: app);
final auth = FirebaseAuth.instanceFor(app: app);
Common pitfalls
Same Firebase project for dev and production. Dev test data pollutes production analytics. Create separate Firebase projects per environment from day one — migrating later is painful.
Forgetting SHA fingerprints on Android. Firebase Auth (Google Sign-In) and Dynamic Links require your debug and release SHA-1/SHA-256 fingerprints to be registered in the Firebase console. The dev and prod apps have different keystores, so both need to be registered.
iOS Crashlytics not uploading dSYMs. Crashlytics needs the GoogleService-Info.plist at compile time via a build script, not just at runtime. If you use runtime initialization only, crash reports show mangled stack traces.
Different bundle IDs not matching the provisioning profile. Each flavor (com.example.myapp.dev vs com.example.myapp) needs its own App ID in the Apple Developer portal and its own provisioning profile.
Sign in to like, dislike, or report.