← Back to posts Cover image for Mastering Multi-Environment Deployments in Flutter: Staging, Production & Beyond

Mastering Multi-Environment Deployments in Flutter: Staging, Production & Beyond

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

As your Flutter app grows from a simple prototype to a mission-critical application, managing different environments becomes essential. You need a safe playground (staging) to test new features without risking your live user data. Yet, many developers find themselves hard-coding API endpoints or toggling boolean flags, leading to messy code and deployment headaches. Let’s fix that.

The core problem is configuration entanglement. Your app needs to know which backend to talk to, which API keys to use, and which features to enable—all dependent on whether it’s a staging or production build. Mixing these configurations is a recipe for accidentally pointing your production app at a test database.

The Foundation: Flavors (or Schemes)

Flutter doesn’t have a built-in “environment” concept, but it provides a powerful mechanism through product flavors (on Android) and schemes (on iOS). These allow you to create distinct app variants from the same codebase, each with its own identifier and configuration. This is the cleanest way to achieve truly separate staging and production apps that can be installed side-by-side on a device.

Let’s set up two flavors: staging and production.

1. Android Setup (android/app/build.gradle): In the android block of your app/build.gradle, define your flavor dimensions and flavors.

android {
    ...
    flavorDimensions "environment"
    productFlavors {
        staging {
            dimension "environment"
            applicationIdSuffix ".staging"
            resValue "string", "app_name", "MyApp Staging"
        }
        production {
            dimension "environment"
            resValue "string", "app_name", "MyApp"
        }
    }
}

The applicationIdSuffix ensures the staging app gets a different package name (e.g., com.example.myapp.staging), allowing it to be installed alongside the production version (com.example.myapp).

2. iOS Setup (Xcode):

  • Open your iOS project in Xcode (ios/Runner.xcworkspace).
  • Select the Runner project in the project navigator.
  • Go to the Info tab. Under Configurations, duplicate the Debug and Release configurations for your staging environment (e.g., Debug-Staging, Release-Staging).
  • Go to the Build Settings tab. Search for “Product Bundle Identifier”. For your new Staging configurations, add a suffix (e.g., .staging) to the base identifier.
  • Finally, edit the scheme. Click on the current scheme name (next to the play button) > Edit Scheme… > Duplicate Scheme. Name it “Staging”. For each action (Run, Archive, etc.), change the Build Configuration to the corresponding -Staging configuration.

Injecting Configuration at Build Time

With flavors set up, we need to pass environment-specific variables into our Dart code. We’ll use Dart defines via the --dart-define flag during build.

Create a configuration class to hold these values:

// lib/core/config/app_config.dart
class AppConfig {
  final String appName;
  final String baseApiUrl;
  final bool isProduction;

  AppConfig({
    required this.appName,
    required this.baseApiUrl,
    required this.isProduction,
  });

  static AppConfig? _instance;

  static AppConfig get instance {
    if (_instance == null) {
      throw Exception('AppConfig has not been initialized. Call `AppConfig.initialize()` in main.dart');
    }
    return _instance!;
  }

  static void initialize({required String appName, required String baseApiUrl, required bool isProduction}) {
    _instance = AppConfig(
      appName: appName,
      baseApiUrl: baseApiUrl,
      isProduction: isProduction,
    );
  }
}

Now, in your main.dart, initialize this configuration before running the app:

// lib/main.dart
import 'package:flutter/material.dart';
import 'core/config/app_config.dart';

void main() {
  // These values will be passed from the command line during build.
  const appName = String.fromEnvironment('APP_NAME', defaultValue: 'MyApp');
  const baseApiUrl = String.fromEnvironment('BASE_API_URL', defaultValue: 'https://api.default.com');
  const isProduction = bool.fromEnvironment('IS_PRODUCTION', defaultValue: false);

  AppConfig.initialize(
    appName: appName,
    baseApiUrl: baseApiUrl,
    isProduction: isProduction,
  );

  runApp(const MyApp());
}

Building and Running with Flavors

You can now build your app with specific configurations:

# Build and run the staging app
flutter run --flavor staging --dart-define=APP_NAME="MyApp Staging" --dart-define=BASE_API_URL="https://staging-api.example.com" --dart-define=IS_PRODUCTION=false

# Build the production APK
flutter build apk --flavor production --dart-define=APP_NAME="MyApp" --dart-define=BASE_API_URL="https://api.example.com" --dart-define=IS_PRODUCTION=true

# Build the production IPA for iOS
flutter build ipa --flavor production --dart-define=APP_NAME="MyApp" --dart-define=BASE_API_URL="https://api.example.com" --dart-define=IS_PRODUCTION=true

Common Mistake Alert: Forgetting to set up the iOS schemes correctly is the number one cause of flavor issues on iOS. Double-check your Build Configurations in Xcode. Also, avoid using complex data types in --dart-define; stick to strings, booleans, and numbers.

Integrating with CI/CD

The real power unlocks in your CI/CD pipeline (e.g., GitHub Actions, Codemagic, Bitrise). Your pipeline scripts should contain the exact build commands for each environment.

A typical staging CI job might:

  1. Check out the code from the develop branch.
  2. Run tests.
  3. Execute the flutter build command with the staging flavor and corresponding --dart-define arguments.
  4. Upload the generated artifact to a testing service (like Firebase App Distribution).

The production job would:

  1. Check out the main branch.
  2. Run more extensive tests.
  3. Build with the production flavor.
  4. Upload to the Play Store Internal Track or TestFlight, or even promote to production.

Beyond Staging and Production

This pattern scales. You can add a demo flavor for sales, or a development flavor with verbose logging. Use the AppConfig class throughout your app to choose endpoints, enable/disable features, or change UI strings.

By separating your builds at the flavor level and injecting configuration, you create a robust, mistake-proof deployment process. Your staging app is truly isolated, and you can deploy both versions with confidence, knowing there’s no hidden connection between them. Start implementing flavors today—it’s a professional foundation for your app.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Developers often face performance bottlenecks when performing expensive operations like date formatting directly within Flutter's `build` method, especially in fast-scrolling lists. This post will delve into common pitfalls, explain why these operations are costly, and provide practical strategies for optimizing UI performance by caching formatters, using `initState`, and leveraging `compute` for background processing without blocking the UI.

Cover image for Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Flutter developers frequently seek to refine their development environments. This post will dive into popular IDE choices like VS Code and Android Studio, discuss best practices for managing iOS and Android simulators (including in-IDE options), and explore the practical integration of AI tools for code generation and problem-solving to boost overall efficiency.

Cover image for Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Flutter's performance is often blamed for issues in complex applications, but the real culprits are usually architectural decisions, inefficient widget rebuilds, and unoptimized resource handling. This post will dive into common performance bottlenecks in large Flutter apps, providing actionable strategies for profiling, optimizing state management, handling images and network requests efficiently, and leveraging CI/CD for continuous performance monitoring.