Skip to content
FlutterLearn

Advanced · Lesson 6 · 13 min read

Release Builds, Signing and CI/CD

Configure flavours and environments, sign Android and iOS builds properly, shrink your app, and automate the whole release in CI.

Updated July 31, 2026

What you will learn

  • Build signed release artifacts for Android and iOS
  • Separate dev, staging and production configuration
  • Reduce app size and enable obfuscation
  • Automate builds and store uploads in CI

Everything up to this point runs on your machine. Shipping introduces a different set of problems: signing keys, environment configuration, store requirements, and making the whole thing reproducible so a release does not depend on one laptop.

Build modes

ModeCompilationUse for
DebugJIT, assertions on, hot reloadDevelopment only
ProfileAOT with tracing keptPerformance measurement
ReleaseAOT, assertions stripped, minifiedEverything you ship
Terminal
# Android — App Bundle is required for the Play Store
flutter build appbundle --release

# APK for direct distribution or testing
flutter build apk --release --split-per-abi

# iOS — then archive and upload from Xcode or via CI
flutter build ipa --release

# Web
flutter build web --release

Compile-time configuration

Use --dart-define rather than checked-in config files. Values are compile-time constants, so tree shaking removes unused branches and nothing environment-specific sits in your repository.

lib/config/env.dart
class Env {
  static const apiUrl = String.fromEnvironment(
    'API_URL',
    defaultValue: 'https://api.dev.example.com',
  );

  static const environment = String.fromEnvironment(
    'ENVIRONMENT',
    defaultValue: 'development',
  );

  static const enableAnalytics = bool.fromEnvironment(
    'ENABLE_ANALYTICS',
    defaultValue: false,
  );

  static bool get isProduction => environment == 'production';
}
Terminal
flutter build appbundle --release \
  --dart-define=API_URL=https://api.example.com \
  --dart-define=ENVIRONMENT=production \
  --dart-define=ENABLE_ANALYTICS=true

# Or keep them in a file, so CI and developers stay in sync
flutter build appbundle --release --dart-define-from-file=config/prod.json

Android signing

Terminal
keytool -genkey -v -keystore ~/upload-keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 -alias upload
android/key.properties (gitignored)
storePassword=<password>
keyPassword=<password>
keyAlias=upload
storeFile=/Users/you/upload-keystore.jks
android/app/build.gradle.kts
val keystoreProperties = Properties().apply {
    val file = rootProject.file("key.properties")
    if (file.exists()) load(FileInputStream(file))
}

android {
    signingConfigs {
        create("release") {
            keyAlias = keystoreProperties["keyAlias"] as String?
            keyPassword = keystoreProperties["keyPassword"] as String?
            storeFile = keystoreProperties["storeFile"]?.let { file(it) }
            storePassword = keystoreProperties["storePassword"] as String?
        }
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
            isMinifyEnabled = true
            isShrinkResources = true
        }
    }
}

Flavours

Flavours let dev, staging and production apps coexist on one device with different names, icons and bundle ids.

android/app/build.gradle.kts
flavorDimensions += "env"

productFlavors {
    create("dev") {
        dimension = "env"
        applicationIdSuffix = ".dev"
        resValue("string", "app_name", "FlutterLearn Dev")
    }
    create("staging") {
        dimension = "env"
        applicationIdSuffix = ".staging"
        resValue("string", "app_name", "FlutterLearn Staging")
    }
    create("prod") {
        dimension = "env"
        resValue("string", "app_name", "FlutterLearn")
    }
}
Terminal
flutter run --flavor dev --dart-define-from-file=config/dev.json
flutter build appbundle --flavor prod --dart-define-from-file=config/prod.json

On iOS the equivalent is a scheme plus a build configuration per flavour, with an xcconfig file supplying the bundle id and display name.

Size and obfuscation

Terminal
# Obfuscate Dart symbols and keep the mapping for crash de-symbolication
flutter build appbundle --release \
  --obfuscate \
  --split-debug-info=build/symbols/prod

# Find out what is actually large
flutter build apk --release --analyze-size
  • Keep the symbol files. Without them, obfuscated crash reports are unreadable. Archive them per release alongside the artifact.
  • Audit assets. Uncompressed PNGs and bundled fonts are usually the largest contributors. Ship WebP, and subset fonts to the glyphs you use.
  • Drop unused dependencies. Every package adds Dart code and sometimes native libraries.
  • Use `--split-per-abi` for APKs so each device downloads only its architecture. App Bundles handle this automatically.

CI with GitHub Actions

.github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']

jobs:
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'

      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.35.0'
          cache: true

      - run: flutter pub get
      - run: flutter analyze
      - run: flutter test --coverage

      # Reconstruct the keystore from a base64 secret
      - name: Decode keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/upload.jks
          cat > android/key.properties <<EOF
          storePassword=${{ secrets.STORE_PASSWORD }}
          keyPassword=${{ secrets.KEY_PASSWORD }}
          keyAlias=upload
          storeFile=${{ github.workspace }}/android/upload.jks
          EOF

      - name: Build App Bundle
        run: |
          flutter build appbundle --release \
            --obfuscate --split-debug-info=build/symbols \
            --dart-define-from-file=config/prod.json

      - uses: actions/upload-artifact@v4
        with:
          name: release-bundle
          path: |
            build/app/outputs/bundle/release/app-release.aab
            build/symbols

      - name: Upload to Play Store (internal track)
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
          packageName: dev.flutterlearn.app
          releaseFiles: build/app/outputs/bundle/release/app-release.aab
          track: internal

Versioning and crash reporting

pubspec.yaml
# version: <semantic version>+<build number>
# The build number must increase with every store upload
version: 1.4.2+58
Terminal
# Override at build time from CI, e.g. with the run number
flutter build appbundle --build-name=1.4.2 --build-number=${{ github.run_number }}

Finally, wire crash reporting before you ship, not after your first bad release. Catch both Flutter framework errors and raw platform errors, and upload the symbol files from your obfuscated build so stack traces are readable.

Dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // Errors from the Flutter framework
  FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;

  // Errors from the underlying platform / async zones
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true;
  };

  runApp(const MyApp());
}

Key takeaways

  • Ship release builds only; use --dart-define for environment config, never for secrets.
  • Back up your Android keystore somewhere you cannot lose it.
  • Obfuscate with --split-debug-info and archive the symbols with each release.
  • Automate analyze → test → build → upload in CI so releases never depend on one machine.

Practice

Automate a release end to end

Set up dev/staging/prod flavours with separate bundle ids and API URLs, wire signing from CI secrets, and create a workflow that runs analyze and tests, builds an obfuscated App Bundle on a version tag, and uploads it to an internal track with the symbol files attached to the run.

Show hints
  • Test the workflow on a throwaway tag before pointing it at your real listing.
  • Store the keystore as a base64 secret and reconstruct it at build time.