Flutter flavors in CI: building dev, staging, and production automatically
By Ann Tech · 9 May 2026
Once you have Flutter flavors set up locally, the next step is automating builds in CI so every merge to main produces signed builds for all environments without anyone touching a terminal.
The workflow structure
merge to main
├── build dev APK → upload to Firebase App Distribution (testers)
├── build staging APK → upload to Firebase App Distribution (QA)
└── build production bundle → upload to Play Store internal track
Android signing setup
Store your keystore as a base64-encoded GitHub secret:
# Encode keystore
base64 -i android/app/my-release.keystore | pbcopy
Add these secrets in GitHub → Settings → Secrets:
KEYSTORE_BASE64KEYSTORE_PASSWORDKEY_ALIASKEY_PASSWORD
android/app/build.gradle.kts:
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_PATH") ?: "keystore.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: ""
keyAlias = System.getenv("KEY_ALIAS") ?: ""
keyPassword = System.getenv("KEY_PASSWORD") ?: ""
}
}
buildTypes {
release { signingConfig = signingConfigs.getByName("release") }
}
The full CI workflow
.github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
jobs:
build-android:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- flavor: dev
target: lib/main_dev.dart
track: ''
- flavor: staging
target: lib/main_staging.dart
track: ''
- flavor: production
target: lib/main.dart
track: internal
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
cache: true
- run: flutter pub get
- name: Decode keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
- name: Build ${{ matrix.flavor }}
run: |
flutter build appbundle \
--flavor ${{ matrix.flavor }} \
-t ${{ matrix.target }} \
--obfuscate \
--split-debug-info=build/debug-info \
--dart-define=ENVIRONMENT=${{ matrix.flavor }}
env:
KEYSTORE_PATH: keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Upload dev/staging to Firebase
if: matrix.flavor != 'production'
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ matrix.flavor == 'dev' && secrets.FIREBASE_APP_ID_DEV || secrets.FIREBASE_APP_ID_STAGING }}
token: ${{ secrets.FIREBASE_TOKEN }}
groups: testers
file: build/app/outputs/bundle/${{ matrix.flavor }}Release/app-${{ matrix.flavor }}-release.aab
- name: Upload production to Play Store
if: matrix.flavor == 'production'
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_JSON }}
packageName: com.example.myapp
releaseFiles: build/app/outputs/bundle/productionRelease/app-production-release.aab
track: internal
iOS signing in CI
For iOS, use Fastlane Match to manage certificates in a git repo:
# fastlane/Fastfile
platform :ios do
lane :build_staging do
match(type: 'adhoc', readonly: true)
sh "flutter build ipa --flavor staging -t lib/main_staging.dart"
firebase_app_distribution(
app: ENV['FIREBASE_APP_ID_IOS_STAGING'],
groups: 'testers',
ipa_path: 'build/ios/ipa/Runner.ipa',
)
end
end
# In GitHub Actions (must use macos runner for iOS):
build-ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
cache: true
- run: cd ios && pod install
- run: bundle exec fastlane ios build_staging
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
FIREBASE_APP_ID_IOS_STAGING: ${{ secrets.FIREBASE_APP_ID_IOS_STAGING }}
Common pitfalls
Hardcoding version numbers. Use pubspec.yaml as the single source of truth for version and build number. Extract them in the CI script: flutter build appbundle --build-number=$GITHUB_RUN_NUMBER.
Not uploading debug symbols. --split-debug-info writes symbols to build/debug-info/. Upload these to Crashlytics or Sentry after the build so crashes are symbolicated in the dashboard.
Building all flavors on every PR. Full builds are slow (5-10 minutes each). Run only tests and analysis on PRs; trigger flavor builds only on merges to main or via manual dispatch.
Sign in to like, dislike, or report.