White-labeling one Flutter app for many customers: how I did it
A learning platform ships a Flutter mobile app. Enterprise customers want their own app on the App Store and Google Play:
their name, icon, splash screen, bundle id and deep-link domain, but still one codebase underneath. This post covers how I
split the problem into three layers, which mechanism each one uses (with code), and what I haven't solved. Customer names,
domains and every ID are hidden or replaced with fake values; I call the base flavor base and an example tenant tenantc.
The problem
"White-label" sounds like swapping a logo, but on mobile it is three different jobs:
- App identity: bundle id, display name, icon, splash, Firebase project, deep-link domain. This is what the stores see and it can't change after build.
- Runtime branding: the logo and title on the login screen, the notification icon. The backend can change these without shipping a new release.
- Delivery: every tenant is a separate app, so it needs its own build, signing and upload, yet the pipeline can't be copy-pasted per tenant.
Forcing all three into one mechanism breaks: put identity on the backend and the stores can't see it; put branding in the build and every logo change becomes a release.
Layer 1: build-time identity
The core idea: one source of truth in .env files, loaded with --dart-define-from-file. The same set of variables feeds
three places: Dart (compile-time constants), Gradle (Android) and Xcode (iOS). Nothing declares a bundle id or app name twice.
One env file per tenant
Each tenant is a flavor plus a file environment_configurations/tenant/.tenantc.env:
COMPANY_NAME=tenantc
APP_DISPLAY_NAME="Tenant C Academy"
APP_LINK_DOMAIN_PREFIX=tenantc.
APP_STORE_PATH=/tenant-c-academy/id0000000000
PROVISIONING_PROFILE_NAME=Tenantc
PROVISIONING_PROFILE_NAME_PREFIX=Prod
GOOGLE_SIGNIN_SERVER_CLIENT_ID=...
GOOGLE_SIGNIN_IOS_URL_SCHEME=...
GOOGLE_SIGNIN_IOS_CLIENT_ID=...What every tenant shares lives in .general.env, so a bundle id is just string concatenation:
APPLICATION_ID_PREFIX=com.
APPLICATION_ID_TYPE=.vle
ROOT_APP_LINK_DOMAIN=example.comAt build time up to five files are layered: API environment, tenant, shared config, and two certificate files. The example shows the first three.
E=environment_configurations # shortened for reading
flutter run --flavor tenantc \
--dart-define-from-file $E/api/.production.env \
--dart-define-from-file $E/tenant/.tenantc.env \
--dart-define-from-file $E/.general.envAdding a tenant means adding exactly one file like this, with no bundle id to invent: com. + tenantc + .vle = com.tenantc.vle.
Dart: reading dart-defines and picking the flavor
On the Dart side the flavor comes from Flutter's appFlavor (set by --flavor), and every other value is a compile-time constant:
Future<void> initializeApp() async {
flavor = Flavor.convert(appFlavor!);
await Firebase.initializeApp(options: flavor.firebaseOptions);
// ...
}
// deep-link domain = tenant prefix + root domain
String get domain =>
const String.fromEnvironment('APP_LINK_DOMAIN_PREFIX') +
const String.fromEnvironment('ROOT_APP_LINK_DOMAIN');Whatever must differ per flavor (Firebase, analytics) is looked up with a switch over the enum, so forgetting a branch for a
new tenant is a compile error, not a runtime one:
ClarityConfig get clarityConfiguration => switch (this) {
Flavor.development ||
Flavor.staging ||
Flavor.base => clarity.baseConfiguration,
Flavor.tenantA => clarity.tenantAConfiguration,
Flavor.tenantC => clarity.tenantCConfiguration,
};Each flavor also has an ApplicationConfigurator registered with injectable per environment. Tenants extend the base config
and override nothing, so the new class only tells DI the flavor exists:
@LazySingleton(as: ApplicationConfigurator)
@tenantc
final class TenantCAppConfig extends BaseAppConfig {
TenantCAppConfig();
}A tenant's assets get bundled only into its own app, thanks to flavors: in pubspec.yaml:
flutter:
assets:
- assets/images/
- path: assets/tenantc/
flavors:
- tenantcGradle: embedding dart-defines in the Android build
When you run flutter build or flutter run, Flutter hands every dart-define to Gradle through the dart-defines property:
KEY=VALUE pairs, each base64-encoded, comma-separated. --flavor is passed along as FLUTTER_APP_FLAVOR. Gradle only has
to decode that string:
val dartDefineVariables = if (project.hasProperty("dart-defines")) {
(project.property("dart-defines") as String)
.split(",")
.associate { entry ->
val decoded = String(
Base64.getDecoder().decode(entry), Charsets.UTF_8
)
val pair = decoded.split("=", limit = 2)
pair[0] to pair.getOrElse(1) { "" }
}
.filterKeys { it.matches(Regex("^[a-zA-Z_][a-zA-Z0-9_]*$")) }
} else {
emptyMap<String, String>()
}Then those variables drive applicationId, the display name and the deep-link domain. The filterKeys line is a guard that
only accepts valid variable names, and split(limit = 2) keeps any = inside a value:
// the repo writes this inline; folded into a helper here
fun define(key: String) = dartDefineVariables[key] ?: ""
android {
defaultConfig {
applicationId = define("APPLICATION_ID_PREFIX") +
define("COMPANY_NAME") +
define("APPLICATION_ID_TYPE") +
define("APPLICATION_ID_SUFFIX")
resValue(
"string",
"app_display_name",
define("APP_DISPLAY_NAME")
)
resValue(
"string",
"app_link_domain",
define("APP_LINK_DOMAIN_PREFIX") +
define("ROOT_APP_LINK_DOMAIN")
)
}
flavorDimensions += "app"
productFlavors {
// used to list every flavor; now only the one being built
create(define("FLUTTER_APP_FLAVOR")) { dimension = "app" }
}
}AndroidManifest.xml no longer holds hardcoded values, it only references the generated resources:
<application android:label="@string/app_display_name" ...>
<intent-filter android:autoVerify="true">
<data android:host="@string/app_link_domain" />
</intent-filter>
</application>Xcode: a build step that turns dart-defines into an xcconfig
iOS is harder because Xcode doesn't read dart-defines. My approach: a script that runs before the build, decodes
DART_DEFINES (same base64 format as above) and writes an .xcconfig file:
# ios/flutter_extract_dart_define.sh (trimmed)
function entry_decode() { echo "${*}" | base64 --decode; }
out=${SRCROOT}/Flutter/DartDefineVariables.xcconfig
rm -rf "$out"
echo "// Generated file; do not edit or commit." > "$out"
IFS=',' read -r -a items <<<"$DART_DEFINES"
valid=(
"FLUTTER_BUILD_NAME=$FLUTTER_BUILD_NAME"
"FLUTTER_BUILD_NUMBER=$FLUTTER_BUILD_NUMBER"
"FLAVOR=$FLAVOR"
)
for item in "${items[@]}"; do
decoded=$(entry_decode "$item")
# only accept KEY=VALUE lines with a valid variable name
if echo "$decoded" | grep -q "^[a-zA-Z_][a-zA-Z0-9_]*=.*$"; then
valid+=("$decoded")
fi
done
printf "%s\n" "${valid[@]}" >> "$out"The script is attached as a scheme Pre-action (Edit Scheme, Build, Pre-actions), next to the stock "Prepare Flutter Framework" step. Each flavor has its own scheme, so each scheme carries this step:
<PreActions>
<ExecutionAction ActionType="...ExecutionActionType.ShellScriptAction">
<ActionContent
title="Extract Dart-define"
scriptText="sh ${SRCROOT}/flutter_extract_dart_define.sh">
</ActionContent>
</ExecutionAction>
</PreActions>The generated file (gitignored) looks like this:
// Generated file; do not edit or commit.
FLUTTER_BUILD_NAME=1.2.3
FLUTTER_BUILD_NUMBER=100
FLAVOR=tenantc
COMPANY_NAME=tenantc
APP_DISPLAY_NAME=Tenant C Academy
APPLICATION_ID_PREFIX=com.
...That file is the base configuration of the Runner target's build configurations. From then on every $(VARIABLE) in build
settings and Info.plist resolves, and the bundle id formula is identical to Android's:
# one line in project.pbxproj, broken up here for readability
PRODUCT_BUNDLE_IDENTIFIER =
"$(APPLICATION_ID_PREFIX)$(COMPANY_NAME)"
"$(APPLICATION_ID_TYPE)$(APPLICATION_ID_SUFFIX)";
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-$(FLAVOR)";
LAUNCH_SCREEN_STORYBOARD = "LaunchScreen$(PROVISIONING_PROFILE_NAME)";In Info.plist, the display name and the Google sign-in URL scheme are plain variable references too:
<string>$(APP_DISPLAY_NAME)</string>
<string>$(GOOGLE_SIGNIN_IOS_URL_SCHEME)</string>Firebase's GoogleService-Info.plist is picked by a separate build phase: the build configuration is named like
Release-tenantc, a regex takes the part after the last dash as the flavor name and copies the matching file from ios/config/:
if [[ $CONFIGURATION =~ -([^-]*)$ ]]; then
environment=${BASH_REMATCH[1]}
fi
FILE=${PROJECT_DIR}/config/${environment}/GoogleService-Info.plist
cp "$FILE" "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app"Icons and native splash: generated from per-flavor config
Icons and splash screens eat the most time if done by hand: every tenant needs a dozen sizes for two platforms, plus dark variants and an Android 12 splash. I let two packages generate all of it, one YAML file per flavor.
Source images live at launcher_icon/android/tenantc.png, launcher_icon/ios/tenantc.png, splash_logo/tenantc.png and
splash_logo/android_12/tenantc.png. The icon config:
# flutter_launcher_icons-tenantc.yaml
flutter_launcher_icons:
android: true
image_path_android: "launcher_icon/android/tenantc.png"
min_sdk_android: 22
adaptive_icon_background: "#ffffff"
adaptive_icon_foreground: "launcher_icon/android/tenantc.png"
adaptive_icon_foreground_inset: 0
ios: true
image_path_ios: "launcher_icon/ios/tenantc.png"
remove_alpha_ios: trueThe splash config:
# flutter_native_splash-tenantc.yaml
flutter_native_splash:
color: "#FFFFFF"
image: splash_logo/tenantc.png
color_dark: "#FFFFFF"
image_dark: splash_logo/tenantc.png
android_12:
image: splash_logo/android_12/tenantc.png
image_dark: splash_logo/android_12/tenantc.png
android: true
ios: true
web: false
android_gravity: center
ios_content_mode: centerThen two commands. Without -f, flutter_launcher_icons scans for every flutter_launcher_icons-<flavor>.yaml and generates
for each flavor; for splash, the -A flag (--all-flavors) does the same:
dart run flutter_launcher_icons
dart run flutter_native_splash:create -AThe output lands where the flavored build picks it up on its own:
- Android icon:
android/app/src/tenantc/res/mipmap-*/ic_launcher.png,drawable-*/ic_launcher_foreground.png, plusmipmap-anydpi-v26/ic_launcher.xml(adaptive icon) andvalues/colors.xml. - Android splash:
android/app/src/tenantc/res/drawable*/splash.png,android12splash.png,launch_background.xmlandvalues*/styles.xmlfor light, dark and Android 12+. - iOS icon: the asset set
AppIcon-tenantc.appiconset, selected byAppIcon-$(FLAVOR)above. - iOS splash:
LaunchImageTenantc.imageset,LaunchBackgroundTenantc.imagesetandLaunchScreenTenantc.storyboard.
The generated adaptive icon is small, and ic_launcher_background comes from adaptive_icon_background in the YAML:
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="0%" />
</foreground>
</adaptive-icon>One detail ties the two worlds together. The splash package names the storyboard after the capitalized flavor name
(LaunchScreenTenantc), while the LAUNCH_SCREEN_STORYBOARD build setting above is composed from PROVISIONING_PROFILE_NAME.
So the env file sets PROVISIONING_PROFILE_NAME=Tenantc to match exactly. It works, but it's a signing variable doing double
duty as an asset-naming input, an implicit coupling a newcomer would never guess.
Layer 2: runtime branding, the backend decides
What's shown at runtime comes from a backend whitelabel endpoint, called with the tenant's domain and a page_name:
@GET('/whitelabel')
Future<HttpResponse<dynamic>> fetchWhitelabel({
@Query('page_name') String? pageName, // 'login', 'notification'
@Query('domain') String? domain,
});The repository pulls logo and title out of the mobile payload. A missing field becomes an empty string rather than an exception:
final config = (((response['items'] as List?)?.firstOrNull
as Map?)?['data'] as Map?)?['mobile'] as Map?;
return LoginConfigurations(
logo: config?['logo'] as String? ?? '',
title: config?['title'] as String? ?? '',
);The UI has two layers of protection: only white-label flavors take the custom branch, and that branch always checks for empty values so it can fall back to the default logo.
/// false for the base flavor and internal environments
bool get isWhiteLabel => switch (this) {
Flavor.development || Flavor.staging || Flavor.base => false,
_ => true,
};
// login_screen.dart
child: flavor.isWhiteLabel && state.logoUrl.isNotEmpty
? VleNetworkImage(image: state.logoUrl)
: AppImages.logo(),Every request also carries an x-tenant-code header (from TENANT_CODE in the env) so the backend knows which tenant it is serving:
options.headers.putIfAbsent('x-tenant-code', () => tenantCode);Layer 3: delivery, one template, one variable block per tenant
CI on GitLab composes small blocks with extends. Adding a tenant means adding one variable block and two jobs (IDs replaced
with fake values):
.common_tenantc_variables_extension:
variables:
FIREBASE_ANDROID_APP_ID:
value: '1:000000000000:android:aaaaaaaaaaaaaaaa'
expand: false
FIREBASE_IOS_APP_ID:
value: '1:000000000000:ios:bbbbbbbbbbbbbbbb'
expand: false
FLAVOR:
value: 'tenantc'
expand: false
APP_NAME:
value: 'Tenantc'
expand: false
PACKAGE_ID: com.$FLAVOR.vle
# push to the stores: manual, only after the base app has been released
distribute_tenantc:
rules:
- !reference [ .tenant_appstore_build_rules, rules ]
extends:
- .distribute_extension
- .common_production_extension
- .common_appstore_variables_extension
- .common_tenantc_variables_extension
# push a test build to Firebase App Distribution for testers
distribute_pre_tenantc:
rules:
- !reference [ .pre_appstore_build_rules, rules ]
- !reference [ .manual_build_rules, rules ]
extends:
- .distribute_extension
- .common_production_extension
- .firebase_distribution_variables_extension
- .common_pre_appstore_variables_extension
- .common_tenantc_variables_extensionFrom FLAVOR, the later steps derive themselves, so nothing else needs declaring:
GYM_SCHEME: $FLAVOR # iOS scheme for Fastlane
# … is the output / certificates folder
SUPPLY_AAB: "…/bundle/${FLAVOR}Release/app-$FLAVOR-release.aab"
FIREBASE_SERVICE_CREDENTIALS_FILE:
"…/$FLAVOR/firebase-console-service-key.json"Each tenant has its own Firebase project, so one customer's analytics and crashes never mix with another's. The app also gets quick updates over the air (Shorebird) and checks the latest build number from a config file on a CDN, keyed per flavor.
How CD runs for one tenant
The variable block above is only the declaration. Here is how a real release moves through the pipeline. A release is a tag
tenant-vX.Y.Z plus a pipeline triggered through the API, and the distribute_* jobs are filtered by the variable
MANUAL_PIPELINE_OPTIONS (build, release or patch):
tag tenant-vX.Y.Z + API (MANUAL_PIPELINE_OPTIONS=release)
│
├─ distribute_base automatic
│ └─ Shorebird release → App Store + Google Play
│ (records the build number)
├─ distribute_tenantc manual
│ └─ same version + build number → tenant C's stores
└─ distribute_pre_tenantc manual
└─ test build → Firebase App DistributionThree modes, three rule sets. The only difference between the base app and a tenant is when (I broke the if conditions across lines for readability; they are unchanged):
# base app: runs right after the earlier stages
.appstore_build_rules:
rules:
- if: >-
$CI_PIPELINE_SOURCE == "api"
&& $MANUAL_PIPELINE_OPTIONS == "release"
&& $CI_COMMIT_TAG =~ /^tenant-v\d+\.\d+\.\d+$/
&& $RELEASE_PASSWORD == $DEFAULT_RELEASE_PASSWORD
when: on_success
# tenant: same condition, but someone has to press play
.tenant_appstore_build_rules:
rules:
- if: >-
$CI_PIPELINE_SOURCE == "api"
&& $MANUAL_PIPELINE_OPTIONS == "release"
&& $CI_COMMIT_TAG =~ /^tenant-v\d+\.\d+\.\d+$/
&& $RELEASE_PASSWORD == $DEFAULT_RELEASE_PASSWORD
when: manual
# internal test build: only builds the chosen tenant
.manual_build_rules:
rules:
- if: >-
$CI_PIPELINE_SOURCE == "api"
&& $MANUAL_PIPELINE_OPTIONS == "build"
&& $TENANT_ENV == $FLAVOR
&& $CI_COMMIT_BRANCH
when: on_successThe patch mode (over-the-air updates through Shorebird, no store review) filters the same way but requires a PATCH_TAG
in semver format. The RELEASE_PASSWORD condition is a guard so nobody triggers a real release by accident.
Each distribute_* job is only a shell. It doesn't build anything itself; it triggers one shared child pipeline and
forwards every variable (including FLAVOR). The real build runs on a dedicated macOS runner, and resource_group makes
tenants build one at a time instead of stepping on each other:
.distribute_extension:
stage: distribution
trigger:
include: "${CI_TEMPLATE_DIRECTORY}build${CI_TEMPLATE_EXTENSION}"
strategy: depend
forward:
pipeline_variables: true
# build.gitlab-ci.yml
build:
tags: [ mobile_distribution ]
resource_group: mobile_distributionTenants share the base app's version and build number. This is the part that took me the most effort to get right. When
the base app is released to the stores, the job writes the build number to a per-version file. Every tenant (and every pre
test build) reads exactly that number back, so one tag produces the same build number in every app. If the file isn't there,
the job stops with a clear error instead of inventing a different number:
# tenants, pre builds and patches reuse the base app's build number
reuse=false
[[ $MANUAL_PIPELINE_OPTIONS == 'patch' ]] && reuse=true
if [[ $MANUAL_PIPELINE_OPTIONS == 'release' ]]; then
[[ $BUILD_OPTION == 'pre' || $FLAVOR != 'base' ]] && reuse=true
fi
if $reuse; then
if [[ ! -f "$BUILD_NUMBER_FILE" ]]; then
echo "Error: release the base app for this version first"
exit 1
fi
export BUILD_NUMBER="$(cat "$BUILD_NUMBER_FILE")"
else
# internal build: bump the counter
export BUILD_NUMBER="$(yq '.build_number + 1' "$BUILD_ENV_FILE")"
fi
# after the base app reaches the stores: record this version's build number
if [[ $MANUAL_PIPELINE_OPTIONS == 'release' && $FLAVOR == 'base' &&
$BUILD_OPTION != 'pre' ]]; then
echo "$BUILD_NUMBER" > "$BUILD_NUMBER_FILE"
fiSo the "base app first, tenants after" order needs no verbal convention: a tenant triggered too early fails by itself.
iOS signing per tenant. Each tenant is a separate bundle id, so it needs its own provisioning profile, for the app and for
the image-notification extension. The profile name is derived from APP_NAME in the variable block, which is why the block must have it:
(cd ios &&
bundle exec fastlane sigh -a $APP_PACKAGE_ID &&
bundle exec fastlane sigh -a "$APP_PACKAGE_ID.ImageNotification")gym(export_options: { provisioningProfiles: {
app_package_id =>
"Prod Tenantc (AppStore)",
"#{app_package_id}.ImageNotification" =>
"Prod Tenantc ImageNotification (AppStore)",
}})Fastlane picks the action by mode. iOS and Android share the same two lanes, build and upload. The build command is
always the five dart-define files from Layer 1, with flavor taken from FLAVOR:
# trimmed from the real Fastfile for readability
def build
case get_manual_pipeline_options
when ManualPipelineOptions::PATCH
# OTA, no store build
shorebird_patch
when ManualPipelineOptions::RELEASE
if is_pre_appstore_build
# test build
flutter_build
export_ipa if is_ios
else
shorebird_release
export_ipa if is_ios
end
else
flutter_build
export_ipa if is_ios
end
end
def upload
if patch?
upload_patch # notification only
elsif release_to_store?
upload_store # deliver (iOS) or supply (Android)
else
upload_firebase_distribution
end
endThe upload is per tenant too: upload_store pushes debug symbols to Crashlytics with that tenant's own Firebase app id
(FIREBASE_IOS_APP_ID / FIREBASE_ANDROID_APP_ID), then calls deliver or supply with PACKAGE_ID.
upload_firebase_distribution uses the service-account key from a folder named after $FLAVOR, then saves the install link so
a later step can post a comment on the Jira ticket.
So what does adding a tenant to CD cost? One variable block, two extends jobs, one Firebase key folder, two provisioning
profiles. No build, signing or upload step has to be rewritten, since they all read from FLAVOR.
Three tenants, three different lessons
I have the git history of all three, so I can compare:
- Tenant A was the expensive one: 331 files changed. Most of it was laying foundations: moving
google-services.jsoninto per-flavor folders, splitting CI into templates, rewriting how Gradle picks a flavor. - Tenant B touched Xcode: roughly 850 changed lines in
project.pbxprojto add build configurations and a scheme. Editing that file by hand is error-prone, so I wrote it into the process to use the Xcode UI instead. - Tenant C (154 files) was still not small, but different in nature: mostly generated assets, plus scripts I wrote so the repeated steps stop being manual: one script downloads icons from Figma, another configures Firebase for a new flavor. The file count didn't fall steadily; what fell was the amount of thinking per tenant.
A lesson about documentation: I wrote the add-a-tenant process as a skill for a coding agent, then let it go stale as the code changed (a package path moved, Gradle became dynamic). Process docs are code too and need checking when things change.
What's still unsolved
- No per-tenant theme. The brand color is still one shared palette; white-labeling today covers logo, title and notification icon.
switch (flavor)is still scattered through the UI: one tenant's own splash, a hidden watermark for one tenant, a support chat enabled for one tenant. Each is small, but together a new tenant may still need Dart changes, not just config.- Adding a tenant is still a developer job. Xcode, store listings and developer accounts don't fully automate.
- A few implicit couplings, like
PROVISIONING_PROFILE_NAMEabove, should be written down or checked by a script, not live in someone's head.
Takeaway
White-labeling a mobile app is three problems with different lifetimes. What the stores see is fixed at build time and composed by formula from one env file. What users see and may change is decided by the backend, with a fallback. What repeats per tenant belongs to CI and scripts. Later tenants get cheaper, but only if you spend the effort to turn the previous tenant's manual steps into something a machine does.