Mastering Multi-Environment Deployments in Flutter: Staging, Production & Beyond
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
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
Runnerproject in the project navigator. - Go to the Info tab. Under Configurations, duplicate the
DebugandReleaseconfigurations for your staging environment (e.g.,Debug-Staging,Release-Staging). - Go to the Build Settings tab. Search for “Product Bundle Identifier”. For your new
Stagingconfigurations, 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
-Stagingconfiguration.
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:
- Check out the code from the
developbranch. - Run tests.
- Execute the
flutter buildcommand with thestagingflavor and corresponding--dart-definearguments. - Upload the generated artifact to a testing service (like Firebase App Distribution).
The production job would:
- Check out the
mainbranch. - Run more extensive tests.
- Build with the
productionflavor. - 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
Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB
Flutter apps are often criticized for their larger-than-native binary sizes. This post will dive into practical, actionable strategies to significantly reduce your Flutter app's footprint, including build configurations, asset optimization, and dependency management, demonstrating how to achieve app sizes under 10MB.
Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls
Implementing in-app subscriptions in Flutter can be surprisingly complex, especially when dealing with cross-platform nuances, receipt validation, and managing different store configurations. This post will delve into the common challenges developers face, compare solutions like RevenueCat with custom implementations, and provide best practices for setting up and maintaining a robust subscription system.
Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter
Android emulators are notorious for hogging RAM, often pushing developer machines into swap and slowing down Flutter development. This post will explore the reasons behind high emulator memory consumption and provide practical, actionable strategies and tools (including a custom-built solution) to significantly reduce RAM usage, ensuring a smoother development experience without sacrificing functionality.