Multiple White-Label Apps with GitHub Forks

Managing white-label applications in a growing mobile ecosystem can quickly become a nightmare—especially when each brand or client needs their own features, visuals, or logic.
At Transapp, we started with a simple approach: a monorepo and a branch per app. But as the number of apps and their differences grew, so did the pain. This post explains why we moved from a branch-based approach to a fork-based model using GitHub, and how it helped us regain control of our white-label app development.
The Problem: Branches per White Label
Our initial structure looked like this:
main
– shared base appapp/whitelabel-a
,app/whitelabel-b
,app/whitelabel-c
, etc. – one branch per white-label variant
Managing white-label apps with long-lived branches quickly became unsustainable. Here’s why:
Merge conflicts were constant
Every update to the sharedmain
branch had to be merged manually into each white-label branch, often leading to repetitive work, diverging file structures, and broken features.Update paralysis set in
Teams delayed merges out of fear of conflict or regression, causing slow feature rollouts and overdue security patches. Over time, technical debt built up in each branch, making updates even harder.No true isolation between clients
Code written for one app could unintentionally affect another. Developers and reviewers struggled to manage overlapping changes across branches.CI/CD pipelines became complex and brittle
Each branch required custom logic for app icons, credentials, and environments. Maintaining version consistency across branches was nearly impossible. Some branches even had sub-branches, leading to a branch explosion and chaotic release workflows.
As the number of clients grew, this structure became more of a burden than a solution.
---
title: Example with Branch Based Solution
---
%%{init: {'theme': 'base'}}%%
gitGraph
commit id: "Initial commit"
commit id: "Base architecture"
branch app/whitelabel-a
checkout app/whitelabel-a
commit id: "Whitelabel-a branding"
branch app/whitelabel-a-feature
checkout app/whitelabel-a-feature
commit id: "Whitelabel-a feature"
checkout app/whitelabel-a
merge app/whitelabel-a-feature
checkout main
branch app/whitelabel-b
checkout app/whitelabel-b
commit id: "Whitelabel b branding"
checkout main
commit id: "Core feature"
checkout app/whitelabel-a
merge main tag: "Manual merge required"
checkout app/whitelabel-b
merge main tag: "Manual merge required"
checkout main
commit id: "Core feature"
commit id: "Core feature"
checkout app/whitelabel-a
merge main tag: "Manual merge required"
checkout app/whitelabel-b
merge main tag: "Manual merge required"
The Solution: Forks for Scalability
We transitioned to a fork-based structure using GitHub:
transapp-core
→ upstream repositorybelice-flutter
,red-flutter
,transporte-det-flutter
, etc. → forks oftransapp-core
Each fork contains its own branding, features, configurations, and store credentials—while remaining connected to the core app:
- Isolation: Every fork can evolve independently
- Control: Teams choose when to sync with the upstream
- Clean History: Each app has a clear Git history
- Custom CI/CD: Pipelines can be tailored per app without worrying about global breakage
Keeping Forks in Sync with Upstream
To ensure forks don’t fall behind, we added a GitHub Action that syncs the fork’s main
branch with the upstream transapp-core/main
daily:
📄 .github/workflows/upstream-sync.yml
name: ⬆️ Update from Upstream
on:
schedule:
- cron: '0 8 * * *' # Every day at 8AM UTC
workflow_dispatch: # Allow manual trigger
jobs:
sync-upstream:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
token: $
- name: Setup Git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Add upstream
run: |
git remote add upstream [email protected]:OrganizationName/organization-repository.git
git fetch upstream
- name: Check for changes
id: check
run: |
git checkout main
git pull origin main
git fetch upstream main
BEHIND=$(git rev-list --count main..upstream/main)
echo "BEHIND=$BEHIND"
echo "has_changes=$([ "$BEHIND" -gt 0 ] && echo true || echo false)" >> $GITHUB_OUTPUT
- name: Exit if no changes
if: steps.check.outputs.has_changes == 'false'
run: echo "Nothing new to sync from upstream."
- name: Check for existing PR
if: steps.check.outputs.has_changes == 'true'
id: pr
env:
GH_TOKEN: $
run: |
prs=$(gh pr list --label update --state open --json number)
echo "PR exists: $prs"
echo "exists=$([ "$prs" != "[]" ] && echo true || echo false)" >> $GITHUB_OUTPUT
- name: Exit if PR already exists
if: steps.pr.outputs.exists == 'true'
run: echo "Update PR already exists, exiting."
- name: Create update branch and merge
if: steps.pr.outputs.exists == 'false'
id: create-branch
run: |
DATE=$(date +%Y-%m-%d)
BRANCH="chore/update-upstream-$DATE"
git checkout -b $BRANCH
git merge upstream/main --no-edit || echo "conflicts=true" >> $GITHUB_OUTPUT
git push origin $BRANCH
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
- name: Create Pull Request
if: steps.pr.outputs.exists == 'false'
env:
GH_TOKEN: $
run: |
gh pr create \
--title "chore: sync with upstream/main" \
--body "Automated daily sync with upstream." \
--head $ \
--base main \
--label update \
--assignee Assignee \
--draft
Simplified GitHub Action: update-upstream.yml
This action does the following:
- Checks if
main
is behindupstream/main
- Skips if no changes are found
- Verifies if an open PR already exists
- If not, creates a new branch, merges upstream, and opens a PR
Why Use a Pull Request Instead of Auto-Merging?
Creating a PR instead of merging directly offers:
- A safe checkpoint to review incoming changes
- A place to resolve conflicts manually if needed
- CI/CD verification before merging to
main
- Better visibility and accountability in the workflow
Before vs After: Branch-Based vs Fork-Based White Label Strategy
Challenge | Branch-Based Approach | Fork-Based Solution |
---|---|---|
Merge Conflicts | Constant manual merging into each white-label branch | Upstream updates pulled via GitHub Actions |
Feature Delivery Delays | Updates postponed to avoid conflict risk | Each fork chooses when to sync safely |
Code Isolation | Client-specific code easily leaks across branches | Forks are completely isolated |
CI/CD Complexity | One big pipeline with too many conditions | One simple pipeline per fork |
Version Tracking | No consistent versioning across branches | Each fork manages its own versioning |
Developer Focus | Frequent context switching between apps | Clear separation between apps |
Git History | Mixed and hard to follow commit history | Clean, app-specific history in each fork |
---
title: Example with Fork Based Solution
---
%%{init: {'theme': 'base'}}%%
gitGraph
commit id: "Initial commit"
commit id: "chore: add whitelabels colors"
branch update/upstream
checkout update/upstream
commit id: "chore: update from upstream"
checkout main
merge update/upstream tag: "update from upstream"
checkout main
commit id: "feat: add new feature"
commit id: "fix: fix bug"
Final Thoughts
Switching from branches to forks wasn’t just a technical shift—it was a mindset change. It gave us cleaner workflows, stronger app isolation, and better collaboration across teams. Most importantly, it helped us scale our mobile ecosystem without scaling our headaches.
If you’re struggling with white-label app chaos, consider GitHub forks. They might just be the structure you need to grow with confidence.