Compile-time config with --dart-define
Environment configuration is one of those problems that seems simple until you're managing a dozen build variants across dev, staging, and production. Using compile-time config with --dart-define gives you a clean, performant way to inject environment-specific values directly into your Flutter binary at build time—no runtime parsing, no configuration files bundled with your app, just values baked into the executable itself.
In this post, I'll walk you through setting up --dart-define for your Flutter projects, show you how to scale it with --dart-define-from-file, and explain why you absolutely must never store secrets in your binary.
Why compile-time configuration matters
Before diving into the mechanics, let's establish why this approach works well for production apps. At my current role, we build the same Flutter codebase for three different environments: development (hitting local servers), staging (QA environment), and production. Without compile-time configuration, we'd need either:
- Runtime config files that get loaded on app startup (adds latency, increases binary size)
- Hardcoded values that we change between builds (error-prone, easy to ship production values to dev)
- API endpoints detected by BuildFlavor at runtime (works, but duplicates logic)
Compile-time configuration solves this elegantly. The Dart compiler replaces every call to String.fromEnvironment with the actual string value during compilation. No runtime overhead, no extra files, no mistakes from manual changes.
Using --dart-define=KEY=VALUE
The simplest form is passing individual values via the command line. Let's say you're building your e-commerce app and need to inject the API base URL.
flutter build apk \
--dart-define=API_URL=https://api.example.com \
--dart-define=APP_NAME=MyStoreOn the Dart side, you retrieve these values using String.fromEnvironment:
const String apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'http://localhost:8080',
);
const String appName = String.fromEnvironment(
'APP_NAME',
defaultValue: 'Development',
);Notice the defaultValue parameter. This ensures your app still builds even if you forget to pass the flag—it falls back to a sensible default. During local development, I usually let these defaults point to localhost services. When building for release, the CI/CD pipeline supplies the real values.
Here's a practical example from a ride-hailing app I worked on:
class Config {
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:3000',
);
static const String mapboxToken = String.fromEnvironment(
'MAPBOX_TOKEN',
defaultValue: '',
);
static const String environment = String.fromEnvironment(
'ENVIRONMENT',
defaultValue: 'development',
);
static bool get isProduction => environment == 'production';
static bool get isStaging => environment == 'staging';
}
// Usage in your service
class LocationService {
final http.Client _client;
LocationService(this._client);
Future<LocationData> reverseGeocode(double lat, double lng) async {
final url = '${Config.apiBaseUrl}/geocode?lat=$lat&lng=$lng';
final response = await _client.get(Uri.parse(url));
// ...
}
}This approach keeps your configuration in one place and makes it obvious what values your build depends on. When a junior developer joins the team, they can see exactly which environment variables are required.
Scaling with --dart-define-from-file
Once you're managing more than a handful of values, passing individual --dart-define flags becomes unwieldy. Flutter supports --dart-define-from-file, which reads key-value pairs from a file.
Create a file called config.prod.env:
API_BASE_URL=https://api.production.com
API_TIMEOUT_SECONDS=30
FEATURE_ANALYTICS_ENABLED=true
FEATURE_DARK_MODE_ENABLED=true
LOG_LEVEL=error
PAYMENT_GATEWAY=stripe
MAPBOX_TOKEN=pk.eyJ1IjoieHl6...(very long token)Then build with:
flutter build apk --dart-define-from-file=config.prod.envOr for iOS:
flutter build ipa --dart-define-from-file=config.prod.env<p></p>In your Config class, you can now read boolean and numeric values by parsing the string:
class Config {
static const String _apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:3000',
);
static const String _apiTimeoutSecondsStr = String.fromEnvironment(
'API_TIMEOUT_SECONDS',
defaultValue: '30',
);
static const String _analyticsEnabledStr = String.fromEnvironment(
'FEATURE_ANALYTICS_ENABLED',
defaultValue: 'false',
);
static String get apiBaseUrl => _apiBaseUrl;
static int get apiTimeoutSeconds => int.parse(_apiTimeoutSecondsStr);
static bool get analyticsEnabled => _analyticsEnabledStr == 'true';
}I keep separate env files for each build variant: config.dev.env, config.staging.env, config.prod.env. My CI/CD pipeline selects the right file based on which branch is being built. This prevents mishaps where a developer accidentally pushes production config files to a development build.
Reading values with String.fromEnvironment
String.fromEnvironment is the core mechanism. It's a compile-time constant, meaning the Dart compiler replaces every reference during compilation—it never runs at runtime. This has important implications:
- Type is always String: You must parse numeric or boolean values yourself
- Must be const: You can only use it in const contexts or assign to const variables
- No dynamic lookups: You can't do
String.fromEnvironment(variableName)wherevariableNameis computed at runtime
Here's how I structure it in larger apps:
abstract class Environment {
static const String apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'http://localhost:8080',
);
static const String appVersion = String.fromEnvironment(
'APP_VERSION',
defaultValue: '0.0.0+dev',
);
static const String environmentName = String.fromEnvironment(
'ENVIRONMENT',
defaultValue: 'development',
);
// Computed properties based on environment
static bool get isDevelopment => environmentName == 'development';
static bool get isProduction => environmentName == 'production';
}
// Inject into your service locator or provider
void setupServiceLocator() {
getIt.registerSingleton<ApiClient>(
ApiClient(baseUrl: Environment.apiUrl),
);
}For a travel booking app I worked on, we used this pattern to swap between internal API endpoints and production endpoints, all determined at compile time.
Never store secrets in the app binary
This is critical: do not use --dart-define for API keys, passwords, tokens, or any credentials.
Your Flutter binary is not secure. Anyone can decompile it and extract strings. If you build an APK or IPA with your Stripe secret key or Firebase admin token baked in, you've compromised it. Someone will run strings app.apk or use a decompiler, find your key, and you'll have a very bad day.
Instead, use --dart-define only for:
- API base URLs (not the full endpoint with credentials)
- Feature flags
- App names or display values
- Build metadata (version, build number, environment name)
- Public tokens that don't grant write access (like public Mapbox keys)
For actual secrets, follow this flow:
- Your backend issues a temporary token or session credential when the user authenticates
- Your app stores this in the platform's secure storage (Keychain on iOS, Keystore on Android)
- All authenticated requests use this stored credential, not a hardcoded key
I've seen junior developers paste Firebase Admin SDK credentials into config files, thinking compile-time injection would keep them safe. It doesn't. The binary is public; everything in it is readable. Use secrets management services like AWS Secrets Manager or HashiCorp Vault for anything sensitive, and have your backend provide temporary credentials to clients.
Common mistakes
Forgetting the defaultValue parameter: Without it, your app won't build if you forget to pass the flag. Always provide sensible defaults, even if they're empty strings or placeholder values. This prevents CI/CD surprises.
Trying to use dynamic variable names: String.fromEnvironment(variableName) where variableName is computed at runtime will fail. The key must be a string literal known at compile time.
Mixing secrets with configuration: I've seen teams keep API keys in their config files. Don't. Separate your non-secret configuration from credentials completely. Use feature flags and endpoints in --dart-define, and fetch actual secrets from secure storage or a backend after authentication.
Assuming different builds share the same binary: They don't. Each build with different --dart-define values produces a different binary. You can't build once and swap config. This is actually a feature—it prevents the wrong config from accidentally being deployed—but it means your CI/CD pipeline must handle building multiple variants if you need them.
Not version-controlling your env files (except secrets): Commit config.prod.env and config.staging.env to version control. Your team needs to know what values they're building with. Exclude only files containing actual secrets, like a secrets.env that CI/CD injects at runtime.
Key takeaways
- Use
--dart-define=KEY=VALUEto inject environment-specific values at compile time, with no runtime overhead --dart-define-from-filescales this to many values; keep separate env files for each build variant- Retrieve values with
String.fromEnvironment(), which is a compile-time constant replaced by the Dart compiler - Always provide
defaultValueto ensure builds don't fail if a flag is forgotten - Parse numeric and boolean values yourself;
String.fromEnvironmentalways returns String - Never store secrets (API keys, tokens, passwords) in
--dart-define; use secure storage or backend-provided credentials instead - Configure only non-sensitive values: base URLs, feature flags, app names, public tokens, build metadata






Comments
Loading comments…