← Articles

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.

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.

Managing Firebase per flavor in Flutter — ANN Tech