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 Performance Deep Dive: Optimizing 'Vibe Coded' Apps for Speed and Responsiveness
Many developers start with 'vibe coding' for rapid prototyping, but this often leads to slow, unresponsive Flutter apps. This post will guide you through identifying performance bottlenecks in your Flutter projects, covering common culprits like unnecessary widget rebuilds, inefficient state management, and debugging differences between debug and release modes, to help you transform a 'vibe coded' app into a smooth, production-ready experience.
Flutter & AI Code Generation: Beyond 'Vibe Coding' for Solo Developers
AI code generation tools are rapidly evolving, but how can Flutter developers, especially solo founders, leverage them effectively without falling into 'vibe coding' pitfalls? This post will explore strategies for using AI to boost productivity, maintain code quality, and ensure architectural consistency in Flutter projects, addressing common concerns like context drift and code reuse.
Flutter vs. KMP: Choosing the Right Cross-Platform Framework for Your Existing Native App
Many companies with existing native apps face the dilemma of choosing between Flutter and Kotlin Multiplatform (KMP) for cross-platform expansion. This post will provide a balanced comparison, discussing the pros and cons of each for teams with established native iOS (Obj-C) and Android (XML) codebases, especially concerning features like Bluetooth/WiFi connectivity, and guiding decision-making for long-term maintainability and developer experience.