By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.
A build and release pipeline is the difference between shipping confidently every week and dreading every release. At iStoria, we ship to 5M+ users on a weekly cadence with 99.9% crash-free sessions. That is not luck — it is a pipeline that builds, tests, signs, and deploys with zero manual steps.
This guide is a reference (not a tutorial) for the full pipeline: flavor configuration, CI/CD setup, store deployment, and the guardrails that prevent bad releases. For a step-by-step flavors tutorial, see our Flutter build flavors article. For the broader release-engineering story (trunk-based development, feature flags, staged rollout), see our release engineering case study.
The goal of this guide is to serve as the reference document you keep open while setting up or auditing your pipeline.
Every production Flutter app needs at least three flavors:
1. Development — local development against a dev backend. No real users. Hot reload. Used by engineers daily. 2. Staging — pre-production environment for QA, beta testing, and release candidates. Mirrors production as closely as possible (same backend, same feature flags, same analytics — just isolated data). 3. Production — the real app served to real users. Different bundle ID, different API keys, different signing credentials.
Each flavor has its own application ID/bundle ID, icon set, and environment configuration. The flavor system is the backbone of the pipeline — it lets you test the exact binary that will ship to users, just pointed at a different backend.
On Android, flavors are defined in build.gradle with productFlavors. On iOS, they are defined via Xcode schemes and build configurations. In Flutter, you select the flavor at build time:
flutter build apk --flavor staging --t lib/main_staging.dart
flutter build ipa --flavor staging --export-options-plist ios/staging/ExportOptions.plist
Each flavor uses a separate entry point (main_dev.dart, main_staging.dart, main_prod.dart) that injects the correct environment configuration before calling runApp().
The CI/CD pipeline runs on every pull request and on every merge to main. The stages:
flutter analyze — static analysis. Zero warnings tolerated.dart format --set-exit-if-changed — enforce formatting.If any gate fails, the PR cannot merge. This is enforced by GitHub branch protection rules.
Signing is the most painful part of the pipeline, and it must be fully automated:
match manages certificates in a private repo. Never commit certificates to the app repo.At iStoria, signing is configured once and never touched. The CI pipeline picks up the credentials from GitHub Actions secrets and signs the binary automatically. No manual Xcode signing, no manual keystore passwords.
Each flavor needs different configuration values: API URLs, API keys, feature flag endpoints, analytics tokens. We use --dart-define and --flavor to inject these at build time:
flutter build apk \
--flavor prod \
--dart-define=API_URL=https://api.istoria.app \
--dart-define=ANALYTICS_TOKEN=prod-token
Inside the app, String.fromEnvironment('API_URL') reads the value. This keeps configuration out of the source code and makes each flavor's config explicit in the CI pipeline.
The pipeline must have guardrails that prevent bad releases:
1. Branch protection — no direct pushes to main. Every change goes through a PR with passing CI. 2. Required reviews — at least one approval from a teammate. For release tags, require two. 3. Crash-free threshold — after a staged rollout to 1%, check the crash-free rate. If it drops below 99.5%, halt the rollout automatically. 4. Feature flags — new features ship behind flags, disabled by default. They are enabled server-side after the release is stable, allowing instant rollback without a new build. 5. Database migration safety — any schema migration must be backward-compatible (additive). Breaking migrations require a multi-release strategy (add new column → populate → remove old column across releases).
At iStoria, we ship every week:
This cadence requires trunk-based development (no long-lived branches) and feature flags (ship code without enabling features). The pipeline is what makes it repeatable — no manual steps, no manual signing, no manual store uploads.
lib/
├── main.dart # production entry point (default)
├── main_dev.dart # development entry point
├── main_staging.dart # staging entry point
├── main_prod.dart # production entry point (explicit)
├── core/
│ └── config/
│ └── env_config.dart # reads String.fromEnvironment values
└── ...
android/
├── app/
│ └── build.gradle # productFlavors: dev, staging, prod
└── ...
ios/
├── Runner.xcodeproj # schemes: dev, staging, prod
├── flutter/
│ ├── staging/ExportOptions.plist
│ └── prod/ExportOptions.plist
└── ...
.github/workflows/
├── pr-check.yml # analyze + test + build (staging) on every PR
├── release.yml # build prod + upload to stores on tag
└── nightly.yml # integration tests + dependency audit
fastlane/ # optional, for store metadata management
├── Fastfile # lanes for upload to Play Store / TestFlight
└── match/ # certificate management (private repo)
// lib/main_dev.dart — development entry point
import 'package:flutter/material.dart';
import 'app.dart';
import 'core/config/env_config.dart';
Future<void> main() async {
EnvConfig.initialize(
apiUrl: const String.fromEnvironment(
'API_URL',
defaultValue: 'https://dev-api.istoria.app',
),
environment: Environment.dev,
analyticsEnabled: false,
);
runApp(const MyApp());
}
// lib/main_prod.dart — production entry point
Future<void> main() async {
EnvConfig.initialize(
apiUrl: const String.fromEnvironment(
'API_URL',
defaultValue: 'https://api.istoria.app',
),
environment: Environment.prod,
analyticsEnabled: true,
);
runApp(const MyApp());
}
// core/config/env_config.dart — central config
enum Environment { dev, staging, prod }
class EnvConfig {
static late String apiUrl;
static late Environment environment;
static late bool analyticsEnabled;
static void initialize({
required String apiUrl,
required Environment environment,
required bool analyticsEnabled,
}) {
EnvConfig.apiUrl = apiUrl;
EnvConfig.environment = environment;
EnvConfig.analyticsEnabled = analyticsEnabled;
}
static bool get isProduction => environment == Environment.prod;
}
# android/app/build.gradle — flavor definitions
android {
flavorDimensions += "default"
productFlavors {
dev {
dimension "default"
applicationIdSuffix ".dev"
versionNameSuffix "-dev"
}
staging {
dimension "default"
applicationIdSuffix ".staging"
versionNameSuffix "-staging"
}
prod {
dimension "default"
}
}
}
# .github/workflows/release.yml — production release pipeline
name: Release
on:
push:
tags: ['v*']
jobs:
build-and-deploy:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.x'
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Run tests
run: flutter test --coverage
- name: Build Android (production)
run: flutter build appbundle \
--flavor prod \
--dart-define=API_URL=https://api.istoria.app
- name: Build iOS (production)
run: flutter build ipa \
--flavor prod \
--export-options-plist ios/prod/ExportOptions.plist \
--dart-define=API_URL=https://api.istoria.app
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: app.istoria
releaseFiles: build/app/outputs/bundle/prodRelease/app-prod-release.aab
track: internal
status: completed
- name: Upload to TestFlight
uses: apple-actions/upload-testflight@v1
with:
app-store-connect-issuer-id: ${{ secrets.ASC_ISSUER_ID }}
app-store-connect-key-id: ${{ secrets.ASC_KEY_ID }}
app-store-connect-private-key: ${{ secrets.ASC_PRIVATE_KEY }}
app-path: build/ios/ipa/istoria.ipa
You can start with two, but staging becomes essential as you scale. Without staging, you test new features against either a local dev backend (which does not match production) or the real production backend (risky). Staging is a pre-production environment that mirrors production as closely as possible — the same build, same backend, isolated data. It is where you catch integration issues before users do.
Use Fastlane match for iOS (stores certificates in a private repo, team members and CI fetch them via a passphrase) and a Google Play service account JSON key for Android (stored as a CI secret). Never commit certificates to the app repo. Configure once, then the CI pipeline handles signing automatically on every build.
Staged rollout releases your app to a small percentage of users first (1%), then gradually increases (10% → 50% → 100%) over hours or days. If the crash-free rate drops or a critical bug surfaces at 1%, you halt the rollout before it reaches the majority of users. This is the single most important guardrail for a 5M-user app — it turns a bad release from a crisis into a minor incident.
Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →
Book a 20-minute call · Download the CV (PDF) · See how I work