Flutter Flavors and Environment Config: Dev, Staging, Prod

Coding Liquids tutorial cover featuring Sagnik Bhattacharya for Flutter Flavors and Environment Config.
Coding Liquids tutorial cover featuring Sagnik Bhattacharya for Flutter Flavors and Environment Config.

Building a production application inevitably leads to a branching problem: you need your development API keys separated from real user data, and you likely want both the testing and live versions installed on your testing device simultaneously. Relying on manual variable swaps before every build is a disaster waiting to happen, often leading to test data corrupting production databases.

Follow me on Instagram@sagnikteaches

This tutorial outlines a robust, professional architecture for environment configuration in Flutter using native "flavors" alongside Dart's modern injection tools. We will dive into Android's build.gradle.kts, Xcode schemes and configurations, separate Dart entry points, and dynamic variable injection to construct distinct development, staging, and production environments.

Connect on LinkedInSagnik Bhattacharya

Before altering native project files, ensure your project is under version control so you can easily revert if a build setting goes awry. We will rely heavily on the --dart-define-from-file command to inject our environment variables, which requires Flutter 3.0 or newer to function correctly.

Subscribe on YouTube@codingliquids
The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Separating Dart Entry Points and Variables

The foundation of environment separation begins in your Dart code. Instead of a single main.dart file filled with fragile if/else statements, standardise your project by creating separate entry points for each environment. This ensures that when you compile the application, only the relevant configuration is included.

First, define a configuration class that reads compile-time variables. We use String.fromEnvironment, which retrieves values passed by the Flutter CLI during the build process.

// lib/core/env/env_config.dart
class EnvConfig {
  static const String appName = String.fromEnvironment(
    'APP_NAME',
    defaultValue: 'MyApp Unknown',
  );
  static const String apiUrl = String.fromEnvironment(
    'API_URL',
    defaultValue: 'http://localhost:8080',
  );
  static const String environment = String.fromEnvironment(
    'ENV',
    defaultValue: 'dev',
  );
}

Next, create JSON files at the root of your project to store these variables. Modern Flutter allows us to pass a single JSON file rather than chaining multiple --dart-define flags.

// config_dev.json
{
  "APP_NAME": "MyApp Dev",
  "API_URL": "https://dev.api.myapp.com",
  "ENV": "dev"
}

// config_prod.json
{
  "APP_NAME": "MyApp",
  "API_URL": "https://api.myapp.com",
  "ENV": "prod"
}

Finally, create your distinct entry points. These files will initialise environment-specific services (like a development-only logging tool) before calling your root widget.

// lib/main_dev.dart
import 'package:flutter/material.dart';
import 'app.dart';
import 'core/env/env_config.dart';

void main() {
  debugPrint('Starting ${EnvConfig.appName} in ${EnvConfig.environment} mode');
  // Initialize dev-specific tools like Chucker or logging here
  runApp(const MyApp());
}

You would mirror this structure for lib/main_prod.dart, omitting the debugging tools to optimise performance.

Configuring Android productFlavors

To install both the development and production versions of your app on an Android device simultaneously, they must have unique Application IDs. We achieve this by modifying the Android build configuration to use productFlavors.

Current Flutter projects use android/app/build.gradle.kts. Inside its android block, add a flavour dimension and create the specific product flavours. The non-production variants append a suffix to the application ID and version name.

android {
    namespace = "com.codingliquids.myapp"
    compileSdk = flutter.compileSdkVersion
    ndkVersion = flutter.ndkVersion

    buildFeatures {
        resValues = true
    }

    flavorDimensions += "default"

    productFlavors {
        create("dev") {
            dimension = "default"
            applicationIdSuffix = ".dev"
            versionNameSuffix = "-dev"
            resValue("string", "app_name", "MyApp Dev")
        }
        create("staging") {
            dimension = "default"
            applicationIdSuffix = ".stg"
            versionNameSuffix = "-stg"
            resValue("string", "app_name", "MyApp Staging")
        }
        create("prod") {
            dimension = "default"
            // Production uses the base applicationId
            resValue("string", "app_name", "MyApp")
        }
    }
    // ... rest of the file
}

The resValues build feature is enabled because these flavours create app_name resources in Gradle; current Android Gradle Plugin versions can reject custom resource values when that feature is disabled.

If an older project still uses android/app/build.gradle, translate the assignments to Groovy syntax instead of pasting this Kotlin DSL block into that file.

To ensure Android uses the dynamic app_name we just defined in resValue, open android/app/src/main/AndroidManifest.xml and update the android:label attribute to reference the string resource.

<application
    android:label="@string/app_name"
    android:name="${applicationName}"
    android:icon="@mipmap/ic_launcher">
    <!-- Activities and metadata -->
</application>

If you encounter a missing resource error during your next build, create a fallback strings file at android/app/src/main/res/values/strings.xml containing a default app name.

Configuring iOS Schemes and Build Settings

iOS configuration is more involved. Apple uses "Configurations" (Debug, Release, Profile) and "Schemes" (the targets you actually run). To support flavors, we must duplicate the configurations for each environment and map them to custom schemes.

Open your iOS project in Xcode (open ios/Runner.xcworkspace). Click on the Runner project in the Project Navigator, select the project file (not the target), and go to the Info tab. Under Configurations, duplicate the existing ones so you have:

  • Debug-dev, Debug-staging, Debug-prod
  • Release-dev, Release-staging, Release-prod
  • Profile-dev, Profile-staging, Profile-prod

Next, we need to create the Schemes. In the top bar next to the play button, click the current scheme (Runner) and select Manage Schemes. Add three new schemes named exactly dev, staging, and prod. The naming must exactly match your Flutter flavor names.

Edit each scheme. For the dev scheme, go through the tabs (Run, Test, Profile, Analyse, Archive) and ensure the Build Configuration dropdown matches the flavor. Run should use Debug-dev, Profile should use Profile-dev, and Archive should use Release-dev.

Finally, to dynamically change the Bundle Identifier and App Name based on the active configuration, click your Runner Target (not the project), go to Build Settings, and search for "Product Bundle Identifier". Expand it and set the values manually:

  • Debug-dev / Release-dev: com.codingliquids.myapp.dev
  • Debug-prod / Release-prod: com.codingliquids.myapp

Repeat this process for the "Product Name" setting to alter the display name on the iOS home screen.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Managing Environment-Specific App Icons

When you have multiple versions of your app installed, identical icons lead to confusion. You should generate distinct icons, perhaps overlaying a "DEV" or "STG" badge on your testing builds.

The easiest way to manage this is via the flutter_launcher_icons package. Instead of a single configuration, create multiple YAML files in your project root.

# flutter_launcher_icons-dev.yaml
flutter_launcher_icons:
  android: true
  ios: true
  image_path: "assets/icons/icon_dev.png"

# flutter_launcher_icons-prod.yaml
flutter_launcher_icons:
  android: true
  ios: true
  image_path: "assets/icons/icon_prod.png"

The package recognises files named flutter_launcher_icons-<flavor>.yaml. Run the current executable once and it discovers each named flavour configuration, placing generated assets in the corresponding native flavour directories. Do not add --flavor: version 0.14.4 has no such command-line option.

dart run flutter_launcher_icons

On Android, this generates an android/app/src/dev/res folder. When the dev flavor is built, Gradle merges these resources, overriding the main icon.

Running and Building Flavored Apps

With the native configurations and Dart entry points established, you must update your CLI commands to tie everything together. A standard flutter run will no longer work, as the tooling won't know which flavor or variables to use.

To run the development environment, execute:

flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=config_dev.json

To build a production App Bundle for the Google Play Store:

flutter build appbundle --flavor prod -t lib/main_prod.dart --dart-define-from-file=config_prod.json

Because these commands are lengthy and prone to typos, it is highly recommended to encapsulate them in a Makefile, a shell script, or within the launch configurations of VS Code (launch.json) or Android Studio.

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "MyApp (Dev)",
      "request": "launch",
      "type": "dart",
      "program": "lib/main_dev.dart",
      "args": [
        "--flavor",
        "dev",
        "--dart-define-from-file=config_dev.json"
      ]
    }
  ]
}

Troubleshooting Common Configuration Mistakes

Environment setup requires touching sensitive native files, which often results in build errors. Here are the most common pitfalls and how to fix them.

"Cannot find a scheme matching flavor on iOS": This occurs when the --flavor argument passed to the CLI does not have a perfectly matching Xcode Scheme. Remember, scheme names are case-sensitive. If your flavor is dev, your scheme must be dev, not Dev or Runner-dev.

Variables returning empty strings: If String.fromEnvironment returns its default value unexpectedly, ensure you are declaring the variable as a const. The fromEnvironment constructor only evaluates during compile-time. If you assign it to a non-const variable or use it inside a runtime function without a const declaration, it will fail silently.

Missing Google Services files: If you use Firebase, each flavor likely needs its own google-services.json and GoogleService-Info.plist because the bundle IDs differ. On Android, place the JSON file in android/app/src/dev/ and android/app/src/prod/. On iOS, you must write a custom Build Phase script in Xcode to copy the correct plist file based on the active configuration.

Verifying Your Environments

Before trusting your new setup, write a small verification widget. This ensures the Dart variables are correctly injected and the native build tools haven't dropped the configuration.

Add this simple banner to your app's home screen. It reads the EnvConfig class we created earlier and conditionally displays a red warning banner if the app is not running in production mode.

import 'package:flutter/material.dart';
import 'core/env/env_config.dart';

class EnvironmentBanner extends StatelessWidget {
  const EnvironmentBanner({super.key, required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    if (EnvConfig.environment == 'prod') {
      return child; // No banner in production
    }

    return Directionality(
      textDirection: TextDirection.ltr,
      child: Banner(
        location: BannerLocation.topStart,
        message: EnvConfig.environment.toUpperCase(),
        color: Colors.red.shade800,
        child: child,
      ),
    );
  }
}

Wrap your Scaffold or root widget in EnvironmentBanner. When you run your dev build, you should see the red banner, confirming that your command-line arguments successfully penetrated the build process and reached your Dart code.

Further reads

Keep going with these related tutorials from this site.