Flavors in App Development: Android & iOS Production, Staging, and Development Environments Explained
If you've ever pushed a build to testers and quietly prayed it wasn't pointing at the live payment gateway, this guide is for you. Build flavors are the boring-but-essential machinery that keeps your dev, QA, and real customers from ever touching the same data. Here's how they actually work across Android, iOS, and Flutter.
- Introduction
- What are flavors?
- Why multiple environments?
- Dev vs Staging vs Production
- Android product flavors
- Build types vs product flavors
- iOS schemes, targets & configs
- Flutter flavors
- Managing API URLs & config
- Real-world example
- Project structure
- CI/CD & automated builds
- Common mistakes
- Best practices
- When to use flavors
- Android vs iOS vs Flutter
- FAQ
- Conclusion
Introduction
Every non-trivial app eventually needs more than one version of itself. Not different features — the same app, but talking to different servers, different databases, and different third-party accounts depending on who's running it and why.
A developer testing a checkout flow should never hit the real Stripe account. A QA tester verifying a release should be looking at production-like data, but not actual customer orders. And your real users should never, under any circumstance, end up connected to a half-broken experimental backend.
The mechanism that makes this clean and repeatable is called a flavor (Android/Flutter), or a combination of schemes, targets, and build configurations (iOS). This article walks through all three, using a fictional shopping app called MyShop as the running example.
What Are Flavors in App Development?
A flavor (also called a build variant or build configuration) is a way to produce multiple distinct versions of a single app from one codebase. Each variant can differ in:
- The application/bundle identifier (so all three can be installed side by side)
- The app name and launcher icon
- API base URLs and other endpoints
- Firebase / analytics / crash-reporting projects
- API keys (Maps, push, third-party SDKs)
- Signing configuration and feature flags
The important idea: it's one project and one set of source files. You're not maintaining three copies of your app — you're maintaining one, and letting the build system swap in the right configuration at compile time.
Why Do We Need Different Environments?
Imagine MyShop has no environment separation — everything is hardcoded to production. Now watch what happens during normal development:
- A developer testing "delete account" runs it against the live database and wipes a real user.
- QA testing refunds triggers real money movements through the live payment gateway.
- Automated tests spam real push notifications to real customers at 2 a.m.
- A crash during an experiment pollutes your production crash dashboard, hiding genuine issues.
Separate environments give each group a safe sandbox. Flavors are how you attach the app to the right sandbox automatically, instead of relying on someone remembering to change a URL.
Development vs Staging vs QA vs Production
People use these terms slightly differently, but here's the common industry meaning:
| Environment | Who uses it | Purpose |
|---|---|---|
| Development | Developers | Fast, throwaway testing against local or dev servers. Debug logging fully on. Breakage expected. |
| QA / Testing | QA engineers | Structured verification of features and bug fixes. Often shares the dev or staging backend depending on team size. |
| Staging | QA + internal testers | A production-like mirror. Same infrastructure shape as production, but isolated data. The last checkpoint before release. |
| Production | Real customers | The live app. Live data, live payments, debug logging off, maximum stability. |
Small teams sometimes collapse QA and staging into one, or dev and QA into one. That's fine — the principle is what matters: isolate the things that can hurt real users.
Android Product Flavors
On Android, environment separation is handled almost entirely by Gradle using productFlavors. A product flavor is a named configuration you define in your module-level build.gradle (or build.gradle.kts). Gradle then multiplies your flavors by your build types to generate every possible build variant.
A minimal Groovy example
android {
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev"
versionNameSuffix "-dev"
resValue "string", "app_name", "MyShop Dev"
}
staging {
dimension "environment"
applicationIdSuffix ".staging"
versionNameSuffix "-staging"
resValue "string", "app_name", "MyShop Staging"
}
production {
dimension "environment"
resValue "string", "app_name", "MyShop"
}
}
}
Line by line, in plain English
flavorDimensions "environment"— declares a category (a "dimension") that our flavors belong to. You need at least one; here we only care about environment.productFlavors { ... }— the block where each variant is defined.dev { ... }— one flavor nameddev. The name becomes part of variant names and task names (e.g.assembleDevDebug).dimension "environment"— assigns this flavor to the dimension we declared. Required for every flavor.applicationIdSuffix ".dev"— appends.devto the base application ID, givingcom.myshop.app.dev. This is what lets all three installs coexist on one phone.versionNameSuffix "-dev"— tags the version string (e.g.1.4.0-dev) so testers can see at a glance which build they have.resValue "string", "app_name", "MyShop Dev"— generates a string resource at build time, giving each flavor a different visible app name.- The
productionflavor has no suffix — it keeps the cleancom.myshop.appID that ships to the Play Store.
Build types × flavors = variants
Android has build types (by default debug and release) and product flavors. Gradle combines them, so three flavors × two build types produces six variants:
devDebug,devReleasestagingDebug,stagingReleaseproductionDebug,productionRelease
You'll usually develop with devDebug, hand testers a stagingRelease (release build so it behaves like the real thing, but pointed at staging), and ship productionRelease.
Per-flavor everything
Gradle supports a per-flavor source set: a folder named after the flavor (src/dev/, src/staging/, src/production/) whose contents override or add to src/main/. That's how you give each flavor its own:
- App name & launcher icon — drop different
ic_launcherresources in each flavor'sres/. - API base URL — via
buildConfigField(below) or per-flavor resource files. - Firebase config — place a different
google-services.jsoninsrc/dev/,src/staging/, andsrc/production/. The Google Services plugin picks the right one per variant. - Google Maps / other API keys — inject via manifest placeholders or resources per flavor.
- Signing config — assign different
signingConfigs so production uses your protected release keystore.
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev"
buildConfigField "String", "API_BASE_URL", "\"https://dev-api.myshop.com\""
manifestPlaceholders = [ mapsKey: "DEV_MAPS_KEY" ]
}
production {
dimension "environment"
buildConfigField "String", "API_BASE_URL", "\"https://api.myshop.com\""
manifestPlaceholders = [ mapsKey: "PROD_MAPS_KEY" ]
}
}
In code you then read BuildConfig.API_BASE_URL and never hardcode a URL again.
com.myshop.app.staging has a different application ID than com.myshop.app, the Play Store simply won't accept it in your production listing — the IDs don't match. Combined with a distinct app name ("MyShop Staging") and a different icon, it becomes almost impossible to confuse the two.Why separate application IDs matter
The application ID is Android's unique identity for an installed app. Two apps with the same ID cannot both exist on one device. By suffixing dev and staging, you get: side-by-side installs, separate app data, separate notification channels, and a Play Store that treats staging as a completely different app (so you literally cannot overwrite production by accident).
Suggested app names
- MyShop Dev — obviously not for customers
- MyShop Staging — clearly a test build
- MyShop — the clean production name
src/main.Android: Build Types vs Product Flavors
These two are easy to confuse, so here's the distinction:
| Build Types | Product Flavors | |
|---|---|---|
| Answers | "How is it built?" | "Which variant of the app is it?" |
| Defaults | debug, release | None — you define them |
| Controls | Minification, debuggability, signing, shrinking | Environment, IDs, names, endpoints, Firebase |
| Example | release strips logs & obfuscates | staging points at the staging API |
They're orthogonal and combine freely. stagingRelease means "the staging environment, built the production way (minified, signed, no debug flag)" — exactly what you want to hand to QA for a realistic test.
iOS: Schemes, Configurations & Targets
iOS separates environments differently. Instead of one tidy productFlavors block, you assemble a few Xcode concepts. Most modern setups use schemes + build configurations + .xcconfig files, and reserve extra targets for cases that truly need them.
The building blocks
- Build Configuration — a named set of build settings. Xcode ships with
DebugandRelease; you duplicate these intoDebug-Dev,Release-Staging,Release-Production, etc. This is the closest analog to Android build types + flavors combined. - Scheme — tells Xcode which configuration to use for which action (Run, Test, Archive). You create a "MyShop Dev" scheme that runs the
Debug-Devconfig and archives theRelease-Devconfig. Schemes are what you actually pick from the toolbar. - Target — produces one product (an
.app). One target with multiple configurations is usually enough; you only add extra targets when environments need genuinely different code, entitlements, or app extensions. - .xcconfig file — a plain-text file holding build settings (like the API URL or bundle ID) that a configuration reads. This keeps environment values out of the fragile Xcode UI and in version control.
A practical setup for MyShop
Create three schemes — MyShop Dev, MyShop Staging, MyShop Production — each backed by its own .xcconfig:
// Config/Dev.xcconfig
PRODUCT_BUNDLE_IDENTIFIER = com.myshop.app.dev
PRODUCT_NAME = MyShop Dev
API_BASE_URL = https:/$()/dev-api.myshop.com
// Config/Production.xcconfig
PRODUCT_BUNDLE_IDENTIFIER = com.myshop.app
PRODUCT_NAME = MyShop
API_BASE_URL = https:/$()/api.myshop.com
(The $() trick escapes the // so xcconfig doesn't read it as a comment.) You expose API_BASE_URL to code via an entry in Info.plist that references $(API_BASE_URL), then read it at runtime.
What each environment configures
- Bundle IDs —
com.myshop.app.dev,com.myshop.app.staging,com.myshop.app(side-by-side installs, just like Android). - API URLs — per-config in the
.xcconfig. - Firebase — ship a different
GoogleService-Info.plistper environment and copy the right one in during a build phase (or place them in per-scheme folders). - Push notifications — dev builds use the APNs sandbox; production uses the production APNs environment. This is driven by the
aps-environmententitlement and the provisioning profile. - App icons & display names — use different asset catalogs /
ASSETCATALOG_COMPILER_APPICON_NAMEandPRODUCT_NAMEper config. - Signing — each bundle ID gets its own certificate and provisioning profile; production uses your distribution profile for App Store / TestFlight.
| Concept | What it is | Android analog |
|---|---|---|
| Scheme | Which config runs for Run/Test/Archive; what you pick in the toolbar | The selected build variant |
| Build Configuration | A named bundle of build settings (URLs, IDs, flags) | Build type + flavor settings |
| Target | The thing that gets built (an .app) | The Gradle module / output APK |
Flutter Flavors
Flutter doesn't invent a new system — it rides on top of the native ones. A Flutter "flavor" maps directly to an Android product flavor and an iOS scheme. When you run flutter run --flavor dev, Flutter tells Gradle to build the dev flavor and tells Xcode to use the matching scheme.
So the setup work is: define the Android flavors and iOS schemes exactly as above, give them matching names (dev, staging, production), and then drive everything from Flutter.
Running each environment
flutter run --flavor dev
flutter run --flavor staging
flutter run --flavor production
For building:
flutter build apk --flavor production --release
flutter build ipa --flavor production --release
Passing environment values with --dart-define
The cleanest way to inject config is --dart-define, which sets compile-time constants your Dart code reads via String.fromEnvironment:
flutter run --flavor dev \
--dart-define=API_BASE_URL=https://dev-api.myshop.com \
--dart-define=ENV=dev
enum Environment { dev, staging, production }
class AppConfig {
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.myshop.com',
);
static const String _env = String.fromEnvironment('ENV', defaultValue: 'production');
static Environment get environment => switch (_env) {
'dev' => Environment.dev,
'staging' => Environment.staging,
_ => Environment.production,
};
static bool get isProduction => environment == Environment.production;
}
Long command lines get tedious, so most teams store them in --dart-define-from-file JSON files (one per environment) or in IDE launch configurations, and wire those into their flavor commands.
A cleaner architecture: config classes per environment
Rather than scattering fromEnvironment calls everywhere, centralize config into one class hierarchy and select it at startup based on the flavor:
abstract class AppConfig {
String get apiBaseUrl;
String get appName;
bool get enableLogging;
}
class DevConfig extends AppConfig {
@override String get apiBaseUrl => 'https://dev-api.myshop.com';
@override String get appName => 'MyShop Dev';
@override bool get enableLogging => true;
}
class ProductionConfig extends AppConfig {
@override String get apiBaseUrl => 'https://api.myshop.com';
@override String get appName => 'MyShop';
@override bool get enableLogging => false;
}
Then a separate entry point per flavor injects the right one:
// main_dev.dart
void main() => bootstrap(DevConfig());
// main_production.dart
void main() => bootstrap(ProductionConfig());
flutter run --flavor dev -t lib/main_dev.dart
flutter run --flavor production -t lib/main_production.dart
Secrets, .env files, and what flavors are NOT
flutter_dotenvand.envfiles are convenient for non-secret config, but remember: anything shipped in the app binary is extractable. A determined user can unpack your APK/IPA and read those strings.- Prefer
--dart-definefor build-time config and keep real secrets on your backend.
Managing API URLs and Environment Configuration
Wherever you land, the golden rule is the same: the environment is chosen by the build, never by a runtime variable a developer edits by hand. A quick comparison of the common approaches:
| Approach | Good for | Watch out for |
|---|---|---|
--dart-define (Flutter) | Compile-time constants, CI-friendly | Long commands — use a JSON file |
buildConfigField (Android) | Per-flavor constants in BuildConfig | Rebuild needed to change |
.xcconfig (iOS) | Per-config settings in version control | Comment-escaping quirks |
.env / dotenv | Convenient non-secret config | Not secure; bundled in the binary |
Real-World Example: The Staging-in-Production Disaster
Here's the failure flavors are designed to prevent. Suppose a MyShop developer, in a hurry, ships a production app that's accidentally pointed at the staging API (or vice versa). Because nothing enforced the mapping, the mistake sails through review. Now:
- Users see test data — fake products, seeded test accounts, placeholder prices.
- Orders vanish — customers place real orders that land in a staging database nobody fulfills.
- Payments fail or misfire — the app hits a test payment gateway, so real purchases don't complete (or, worse the other way, test flows touch live money).
- Notifications go to the wrong place — push campaigns fire from the staging Firebase project and never reach real users, or reach testers instead.
- Data integrity risk — mixing environments can corrupt or pollute whichever database ends up on the receiving end.
How flavors prevent it: the API URL is baked into each build variant. The production build physically cannot contain the staging URL, because that string lives in the staging flavor's config, not production's. There's no runtime toggle to get wrong. The separate application/bundle IDs and distinct app names give a second layer of "you'd have to try really hard to mix these up."
A Practical Flutter Project Structure
lib/
├── config/
│ ├── app_config.dart // abstract base
│ ├── dev_config.dart
│ ├── staging_config.dart
│ └── production_config.dart
│
├── main_dev.dart // entry point → DevConfig
├── main_staging.dart // entry point → StagingConfig
└── main_production.dart // entry point → ProductionConfig
This structure shines when environments differ in more than a URL — say dev enables a debug overlay, staging shows a "TEST BUILD" banner, and production strips both. Each main_*.dart stays tiny (it just picks a config and boots the app), so there's no duplicated app logic. For a truly trivial app with one server and no testers, this is overkill — a single entry point is fine.
CI/CD and Automated Builds
Flavors pay off most when a machine, not a human, decides which one to build. A typical promotion pipeline:
Pull Request
↓
Development Build (auto — devDebug for reviewers)
↓
QA Testing
↓
Staging Build (auto on merge to develop — stagingRelease)
↓
Approval (manual gate)
↓
Production Build (on tag/release — productionRelease)
↓
Google Play / App Store
Tools like GitHub Actions, GitLab CI/CD, Bitrise, and Codemagic map beautifully onto flavors: a job simply runs flutter build appbundle --flavor production --release (or the native equivalent) for the right variant, with secrets injected from the CI vault — never from the repo. Because the flavor determines the endpoint, there's no human editing a URL before release, which is exactly the class of mistake that causes outages.
Common Mistakes
- Pointing the development app at the production API — one careless test can hit live data.
- Using the production Firebase project in staging — test events pollute real analytics and crash reports.
- Sharing one application/bundle ID across environments — no side-by-side installs, and no store-level protection against overwriting production.
- Uploading a staging build to the store — happens when IDs and names aren't differentiated.
- Hardcoding API URLs in source instead of per-flavor config.
- Committing secrets to Git — even in a private repo, this leaks and is hard to fully purge.
- Forgetting to change the app name and icon — testers can't tell builds apart.
- Wrong
google-services.json/GoogleService-Info.plistin a variant — silent breakage of push and analytics. - Mismatched push environments — using APNs sandbox tokens against production, or vice versa, so notifications silently fail.
- Never testing the release build — debug works, then obfuscation or shrinking breaks something only in
release.
Best Practices Checklist
- ✅ Keep dev, staging, and production genuinely separate.
- ✅ Use separate API endpoints per environment.
- ✅ Use separate Firebase projects where analytics/crash isolation matters.
- ✅ Use distinct application/bundle IDs so builds coexist and can't overwrite each other.
- ✅ Store config per environment, never hardcoded in shared source.
- ✅ Drive builds from CI/CD so the flavor is chosen automatically.
- ✅ Keep production credentials in a secrets manager, out of the repo.
- ✅ Always test the release build, not just debug.
- ✅ Clearly label non-production apps (name + icon badge).
- ✅ Automate environment selection; never rely on a developer manually swapping a URL.
When Should You Use Flavors?
| Scenario | Flavors worth it? |
|---|---|
| Tiny personal project, one server, no testers | Usually no — a single build is simpler |
| Startup app with QA + production | Yes — at least dev + production |
| Enterprise application | Absolutely — dev/staging/prod plus CI |
| App serving multiple clients | Yes — a flavor per client is a common pattern |
| White-label product | Yes — flavors swap branding, endpoints, and IDs per brand |
| Separate QA and production backends | Yes — this is the canonical use case |
| Multiple backend servers/regions | Yes — a flavor per region/backend |
Don't add flavors just because you can. For a weekend app that talks to one Firebase project and has no testers, three environments is ceremony you'll resent. Add them the moment a second audience (testers) or a second backend appears.
Full Comparison: Environments & Platforms
| Feature | Development | Staging | Production |
|---|---|---|---|
| API | Dev API | Staging API | Production API |
| Database | Dev DB | Staging DB | Production DB |
| Firebase | Dev project | Staging project | Production project |
| Payment | Test | Test | Live |
| Debug logs | Enabled | Limited | Disabled |
| Users | Developers | QA / Testers | Real users |
| App ID | .dev suffix | .staging suffix | No suffix |
| Mechanism | Android | iOS | Flutter |
|---|---|---|---|
| Primary tool | productFlavors (Gradle) | Schemes + configs + .xcconfig | --flavor over both |
| Unique ID | applicationIdSuffix | PRODUCT_BUNDLE_IDENTIFIER | Inherited from native |
| Inject URL | buildConfigField | .xcconfig + Info.plist | --dart-define |
| Services file | per-flavor google-services.json | per-config GoogleService-Info.plist | configured natively |
| Run command | ./gradlew assembleDevDebug | select scheme + Run | flutter run --flavor dev |
Frequently Asked Questions
What is the difference between staging and production in app development?
Production is the live app your real customers use, connected to real data, live payments, and stable infrastructure. Staging is a production-like mirror used by QA and internal testers — same infrastructure shape, but isolated data so testing can't affect real users. Staging is the last checkpoint before a release ships to production.
How do I use flavors in Flutter?
Define matching Android product flavors and iOS schemes (e.g. dev, staging, production), then run flutter run --flavor dev. Inject environment values with --dart-define or a per-flavor entry point like main_dev.dart. Flutter delegates to Gradle on Android and to the matching Xcode scheme on iOS.
Are Android product flavors and iOS schemes the same thing?
They solve the same problem differently. Android uses one productFlavors block in Gradle. iOS combines build configurations (the settings), schemes (which config runs for Run/Test/Archive), and optionally extra targets. Flutter unifies both behind a single --flavor flag.
Can I install dev, staging, and production versions on the same phone?
Yes — as long as each has a different application ID (Android) or bundle identifier (iOS). Suffixing with .dev and .staging gives each build a unique identity, so all three install side by side with separate data and icons.
Are flavors a security feature for hiding API keys?
No. Flavors organize configuration but don't hide it. Anything compiled into the app binary — including .env values and --dart-define constants — can be extracted from the APK or IPA. Keep real secrets on your backend, and restrict any client-side keys by bundle ID or quota.
Do I need flavors for a small app?
Not always. If your app talks to a single backend and has no separate testers, a single build is simpler and flavors add unnecessary ceremony. Add flavors as soon as you have a second audience (QA/testers) or a second backend to point at.
How do flavors work with CI/CD?
Beautifully — the flavor determines which environment gets built, so a CI job just runs the correct build command (e.g. flutter build appbundle --flavor production --release) with secrets injected from the CI vault. Pull requests can trigger dev builds, merges trigger staging, and tagged releases trigger production, with no human editing endpoints.
What happens if a production app points to the staging API?
Real users hit test data, real orders land in a database nobody fulfills, payments fail or misfire, and notifications go to the wrong audience. Flavors prevent this by baking the correct URL into each build variant, so a production build physically cannot contain the staging endpoint.
Conclusion
Flavors aren't glamorous, but they're one of the highest-leverage pieces of infrastructure you can set up early. They turn "please remember to change the URL before you release" — a rule that will eventually be forgotten — into a guarantee enforced by the build system. Dev stays sandboxed, QA gets a realistic staging mirror, and your customers only ever touch production.
Start small: even just dev + production with distinct IDs and baked-in URLs eliminates the scariest class of mistakes. Add staging and CI automation as your team and testing needs grow. On Android that's productFlavors, on iOS it's schemes and configurations, and in Flutter it's a single --flavor flag tying both together. Set it up once, and stop worrying about which server your app is quietly talking to.

