Distributing Flutter apps with Firebase App Distribution
By Ann Tech · 28 October 2024
Firebase App Distribution lets you ship APKs and IPAs to testers without going through the app stores. It's the right tool for QA builds, beta programs, and showing stakeholders a build before release. This guide covers the full pipeline from build to tester.
When to use App Distribution vs TestFlight vs Play Store internal testing
| Service | Best for |
|---|---|
| Firebase App Distribution | Cross-platform, flexible groups, any tester email |
| TestFlight | iOS, App Store-approved testers, required for external beta |
| Play Store internal testing | Android, must use Google accounts, integrated with Play |
App Distribution works for both platforms, accepts any email address (no Google account required for iOS), and integrates with Fastlane and GitHub Actions.
Setup
- Enable Firebase App Distribution in the Firebase console
- Add
firebase_app_distributionto your Fastfile:
bundle exec fastlane add_plugin firebase_app_distribution
Or install the Firebase CLI:
npm install -g firebase-tools
firebase login
Android distribution lane
# android/fastlane/Fastfile
lane :distribute_staging do
# Build the APK for the staging flavor
gradle(
task: 'assemble',
build_type: 'Release',
flavor: 'staging',
properties: {
'android.injected.signing.store.file' => ENV['KEYSTORE_PATH'],
'android.injected.signing.store.password' => ENV['KEYSTORE_PASS'],
'android.injected.signing.key.alias' => ENV['KEY_ALIAS'],
'android.injected.signing.key.password' => ENV['KEY_PASS'],
}
)
firebase_app_distribution(
app: ENV['FIREBASE_APP_ID_ANDROID_STAGING'],
groups: 'qa-team, stakeholders',
release_notes: release_notes_for_build,
firebase_cli_token: ENV['FIREBASE_CLI_TOKEN'],
)
end
private_lane :release_notes_for_build do
branch = sh('git rev-parse --abbrev-ref HEAD').strip
commit = sh('git log --oneline -5').strip
"Branch: #{branch}\n\nRecent commits:\n#{commit}"
end
iOS distribution lane
# ios/fastlane/Fastfile
lane :distribute_staging do
setup_ci if ENV['CI']
match(
type: 'adhoc',
app_identifier: 'com.example.myapp.staging',
readonly: ENV['CI'] == 'true',
)
build_app(
workspace: 'Runner.xcworkspace',
scheme: 'Staging',
export_method: 'ad-hoc',
)
firebase_app_distribution(
app: ENV['FIREBASE_APP_ID_IOS_STAGING'],
groups: 'qa-team',
release_notes: "Staging build from #{`git rev-parse --abbrev-ref HEAD`.strip}",
firebase_cli_token: ENV['FIREBASE_CLI_TOKEN'],
)
end
Getting the Firebase CLI token for CI
firebase login:ci
# Outputs a token — store as FIREBASE_CLI_TOKEN secret
GitHub Actions workflow
name: Distribute Staging
on:
push:
branches: [staging]
jobs:
distribute-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.22.0'
- uses: ruby/setup-ruby@v1
with:
working-directory: android
bundler-cache: true
- run: flutter pub get
- name: Decode configs
run: |
echo "$GOOGLE_SERVICES" | base64 --decode > android/app/src/staging/google-services.json
echo "$KEYSTORE" | base64 --decode > android/app/keystore.jks
env:
GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES_STAGING }}
KEYSTORE: ${{ secrets.KEYSTORE_BASE64 }}
- name: Distribute
working-directory: android
run: bundle exec fastlane distribute_staging
env:
KEYSTORE_PATH: app/keystore.jks
KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASS: ${{ secrets.KEY_PASS }}
FIREBASE_APP_ID_ANDROID_STAGING: ${{ secrets.FIREBASE_APP_ID_ANDROID_STAGING }}
FIREBASE_CLI_TOKEN: ${{ secrets.FIREBASE_CLI_TOKEN }}
distribute-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
- uses: ruby/setup-ruby@v1
with:
working-directory: ios
bundler-cache: true
- run: flutter pub get && cd ios && pod install
- name: Distribute
working-directory: ios
run: bundle exec fastlane distribute_staging
env:
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
FIREBASE_APP_ID_IOS_STAGING: ${{ secrets.FIREBASE_APP_ID_IOS_STAGING }}
FIREBASE_CLI_TOKEN: ${{ secrets.FIREBASE_CLI_TOKEN }}
Managing tester groups
Create groups in the Firebase console or via CLI:
firebase appdistribution:group:create qa-team "QA Team"
firebase appdistribution:testers:add --emails [email protected] --group qa-team
In Fastlane, reference groups by name:
firebase_app_distribution(
groups: 'qa-team, design-team, stakeholders',
)
Release notes from changelog
Automate release notes from a CHANGELOG.md:
def latest_changelog_entry
content = File.read('../CHANGELOG.md')
entry = content.split(/^## /)[1] # First entry after the heading
entry.lines.first(10).join # Truncate to 10 lines
end
firebase_app_distribution(
release_notes: latest_changelog_entry,
)
In-app update prompts
Install the Firebase App Distribution Flutter SDK to prompt testers when a new build is available:
dependencies:
firebase_app_distribution: ^0.3.0
Future<void> checkForUpdates() async {
try {
final tester = await FirebaseAppDistribution.instance.signInTester();
if (tester == null) return; // Not a registered tester
final release = await FirebaseAppDistribution.instance
.checkForUpdate(requireNewerBuild: true);
if (release != null) {
// Show update dialog
showUpdateDialog(release);
}
} catch (_) {
// Not a distribution build — ignore
}
}
Common pitfalls
Using debug builds. Distribute release builds, even for QA. Debug builds are slow, not representative of production performance, and can't be tested on device farms.
Missing ad-hoc provisioning for iOS. TestFlight uses app-store export. App Distribution needs ad-hoc — different provisioning profiles, different devices list. Testers must have their UDID registered in the profile or in the Firebase tester list.
Token expiry. FIREBASE_CLI_TOKEN from firebase login:ci expires after 1 hour of inactivity on some configurations. Use a service account JSON instead for more reliable CI.
Sign in to like, dislike, or report.