If you've shipped more than one build of a Flutter app, you've seen the pain: prod API keys accidentally in a dev build, QA testing against the wrong Firebase project, or a last-minute keystore dance that blocks a release. I've been there.

Not a paid subscriber? Click here to read this story for free.

In this article, I'll walk you through a practical, battle-tested way to manage configuration across dev/staging/production so your builds are predictable, secure, and CI-friendly. Think of this as the checklist and sample code I wish someone handed me on day one.

Quick Overview

  • Use flavors (Android productFlavors + iOS schemes) for different app identities and assets.
  • Pass runtime config with --dart-define (and const environment declarations when helpful) for CI-controlled variables.
  • Keep local dev convenience (.env with flutter_dotenv) separate from CI secrets.
  • Store runtime secrets securely on the device (Keychain / Keystore via flutter_secure_storage) — don't hardcode tokens.
  • Wire it all into your CI (GitHub Actions, Codemagic, etc.) using encrypted secrets and the platform's Flutter action.

1) First principle: separate identity from configuration from secrets

  • Identity = app id, icon, display name, bundle id — what makes a build a "staging" app versus "production". Flavors are for identity.
  • Configuration = API endpoints, feature flags, analytics toggles. These need to be adjustable at build or runtime. --dart-define is your best friend for CI-controlled configuration.
  • Secrets = API keys, keystore passwords, server tokens. Treat them like nuclear codes: never check into source control, use encrypted CI secrets and secure storage at runtime.

Treating these three concerns separately prevents a lot of accidental leaks and mixed-up builds.

2) Flavors: the canonical way to give each build its own identity

Flavors are the place to put platform-level differences — separate package/bundle identifiers, different icons, or connecting each flavor to a different Firebase project.

Quick idea: create dev, staging, prod flavors. On Android this maps to productFlavors; on iOS it maps to Xcode schemes and build configurations. Flutter docs walk through platform steps to set this up.

Example Android app/build.gradle snippet:

android {
  flavorDimensions "env"
  productFlavors {
    dev {
      applicationId "com.example.app.dev"
      resValue "string", "app_name", "MyApp (Dev)"
    }
    staging {
      applicationId "com.example.app.staging"
      resValue "string", "app_name", "MyApp (Staging)"
    }
    prod {
      applicationId "com.example.app"
      resValue "string", "app_name", "MyApp"
    }
  }
}

On iOS, create separate schemes (Dev, Staging, Prod) and point each scheme to its own GoogleService-Info.plist or config file when needed.

When you combine flavors with different launchers, icons, and Firebase projects, you remove a whole class of errors where a QA build accidentally hits production.

3) Configuration at build-time vs runtime — when to use which

  • Build-time (--dart-define): good for values you must bake into the binary (e.g., build-specific API base URL used to construct const objects, feature flags that determine compile-time behavior). --dart-define is the standard way to pass key/value pairs into the Dart VM at build time.
  • Example build command: flutter build apk --flavor staging -t lib/main_staging.dart \ --dart-define=ENV=staging \ --dart-define=API_BASE=https://staging.api.example.com
  • Runtime (remote config / env file / CI-injected files): good for toggles you may want to flip without rebuilding, or for large config blobs. Use remote config or a secure endpoint to fetch settings at startup.

Pro tip: use --dart-define for CI-driven environment labels and critical endpoints, and keep feature flags in a remote config provider if you'll flip them often.

4) Local development: .env for convenience — but keep it out of CI

Local .env files are developer ergonomics. For Flutter, flutter_dotenv is the widely used package that loads .env into dotenv.env. Use this for local dev only and gitignore your .env.

Example main.dart pattern that merges approaches:

import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'dart:io';

Future<void> main() async {
  await dotenv.load(fileName: ".env"); // local dev convenience
  final env = const String.fromEnvironment('ENV', defaultValue: null)
            ?? dotenv.env['ENV']
            ?? 'dev';
  final apiBase = const String.fromEnvironment('API_BASE',
                    defaultValue: null)
                ?? dotenv.env['API_BASE']
                ?? 'https://dev.api.example.com';
  runApp(MyApp(env: env, apiBase: apiBase));
}

This pattern: CI uses --dart-define (takes precedence), local dev uses .env, and there's a sensible default fallback.

5) Secrets at build time vs runtime

  • Build-time secrets (keystore passwords for Android signing, iOS provisioning) belong in CI secrets — never commit them. CI systems provide encrypted secrets you can reference in the workflow.
  • Runtime secrets (access tokens, refresh tokens) should be stored securely on the device — use flutter_secure_storage or platform Keychain/Keystore wrappers, not plain shared preferences. flutter_secure_storage is the de-facto plugin for encrypted storage.

Example usage:

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
final storage = FlutterSecureStorage();

// write
await storage.write(key: 'access_token', value: token);
// read
final token = await storage.read(key: 'access_token');

When your app needs to refresh tokens, call your auth endpoint and replace the stored token atomically — avoid storing both refresh and access token in plain text.

6) CI integration: how to wire flavors, defines, and secrets together

CI is where you glue everything so builds are reproducible and safe. Use the platform's encrypted secret store (GitHub Actions secrets, Codemagic environment vars, Bitrise secrets, etc.) and inject those into your build commands.

GitHub Actions example (minimal):

name: Flutter CI
on:
  push:
    branches: [ main, staging ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: 'stable'
      - name: Install dependencies
        run: flutter pub get
      - name: Build staging APK
        if: github.ref == 'refs/heads/staging'
        env:
          API_BASE: ${{ secrets.STAGING_API_BASE }}
        run: |
          flutter build apk --flavor staging -t lib/main_staging.dart \
            --dart-define=ENV=staging \
            --dart-define=API_BASE=$API_BASE

That secrets.STAGING_API_BASE is stored encrypted in GitHub Settings → Secrets. Using --dart-define here keeps the binary consistent and avoids baking secrets into code or repos. For signing, keep keystore files in the repo only if encrypted — otherwise load them at build time from secure storage.

The GitHub/Flutter action ecosystem gives you helpers to set up Flutter in Actions. Use them rather than rolling your own runner configuration.

Codemagic / other CI providers: Codemagic offers environment variables, variable groups, and an UI to mark secrets as secret. You can map these variables into your build and write to config files during build steps. Treat CI-provided secret variables as the source of truth for build-time config.

7) File & folder layout that scales

A clean structure reduces cognitive load. Example:

lib/
  main_dev.dart
  main_staging.dart
  main_prod.dart
  src/
    config/
      app_config.dart   // reads const env + runtime fetched config
    services/
      auth_service.dart
    ...
android/
  app/
    src/dev/
    src/staging/
    src/prod/
ios/
  Runner/
    Configs/Dev-Info.plist
    Configs/Staging-Info.plist
    Configs/Prod-Info.plist
.env.dev
.env.staging (private, for local QA)
.env.prod    (never committed)

main_<flavor>.dart just sets flavor-specific bootstrap and calls a shared runApp.

8) Common pitfalls & how to avoid them

  • Pitfall: Committing .env with API keys. Fix: Add .env* to .gitignore. Keep a .env.example with placeholders.
  • Pitfall: Hardcoding production endpoints in code. Fix: Require --dart-define in CI and throw a build-time error or fail early when required defines are missing.
  • Pitfall: Forgetting to change Firebase config for flavors. Fix: Use flavor-specific Firebase files (different google-services.json / GoogleService-Info.plist) per flavor.
  • Pitfall: Leaving tokens in build logs. Fix: Mark variables as secret in CI — these are masked in logs. Never echo secrets.
  • Pitfall: Relying solely on .env for production. Fix: Use CI secrets + --dart-define for production values and keep .env local-only.

9) Example: Compose everything for a staging release

  1. Add staging flavor on Android and scheme on iOS.
  2. Put staging google-services.json / GoogleService-Info.plist in the staging folder.
  3. Store STAGING_API_BASE and keystore password as CI secrets.
  4. CI job runs: flutter pub get flutter build apk --flavor staging -t lib/main_staging.dart --dart-define=ENV=staging --dart-define=API_BASE=$STAGING_API_BASE
  5. Release the produced artifact to your internal distribution channel (Firebase App Distribution, Play Console internal tracks, TestFlight).

10) Security checklist before release

  • All required --dart-define values present in CI (fail the build otherwise).
  • Keystore credentials stored as CI secrets (not in repo).
  • No .env files committed with real secrets.
  • Tokens stored using flutter_secure_storage on the device.
  • Flavor-specific platform configs point to the correct backend (check Firebase project ids, bundle id).

Final notes — patterns I use every day

I use flavors for identity, --dart-define for CI-controlled parameters, .env for local convenience, and flutter_secure_storage for runtime secrets. These four pieces are the smallest, reliable surface area that covers most pain points.

Read more stories: