How to Build a Flutter APK and App Bundle for Google Play

Coding Liquids tutorial cover featuring Sagnik Bhattacharya for Flutter Build APK and App Bundle.
Coding Liquids tutorial cover featuring Sagnik Bhattacharya for Flutter Build APK and App Bundle.

Turning your local Flutter project into a polished, production-ready Android application requires more than just compiling the code. You must secure it with a cryptographic signature, optimise its size, and package it into the exact formats required by the Google Play Store. A misconfigured build can lead to bloated app sizes, rejected submissions, or even the permanent loss of your ability to update your own application.

Follow me on Instagram@sagnikteaches

This guide walks you through the complete Android release pipeline for a Flutter application. We will generate a secure release keystore, configure your Gradle files to automatically handle signing credentials, and execute the terminal commands needed to output both standalone APKs and Google Play App Bundles. We will also explore essential release practices like Dart code obfuscation, ABI splitting, and version bumping.

Connect on LinkedInSagnik Bhattacharya

Before running any release commands, ensure your application builds successfully in debug mode and that you have finalised your app's package name in the Android manifest. Changing your unique package identifier after you have uploaded your first release to the Play Console is strictly prohibited by Google, so verify it before proceeding.

Subscribe on YouTube@codingliquids
The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

App Bundles (.aab) vs. Standalone APKs

When preparing an Android release, Flutter gives you two primary output formats. Understanding when to use each is crucial for a smooth distribution strategy.

The Android App Bundle (AAB) is Google Play's required publishing format for new apps. An AAB does not install directly onto an Android device. Instead, it contains your compiled code, assets, and native libraries so Google Play can generate optimised APKs for each device's screen density, languages, and CPU architecture. This usually reduces what each user has to download.

An APK (Android Package), on the other hand, is a fully compiled, executable file that can be installed directly onto a phone. You will build APKs when you need to distribute your application outside of the Play Store. This includes side-loading the app onto test devices, sending builds to QA teams via Firebase App Distribution, or deploying internal enterprise software directly to employees.

Managing Versions in pubspec.yaml

Google Play requires every new upload to have a higher version number than the previous one. In Flutter, you control both the user-facing version name and the internal build number directly from your pubspec.yaml file.

name: my_production_app
description: A new Flutter project.

# The format is versionName+versionCode
version: 1.0.2+4

The string before the plus sign (1.0.2) is the versionName. This is the semantic version displayed to your users in the Play Store and inside the app's settings. It has no bearing on Google Play's internal upload logic.

The integer after the plus sign (4) is the versionCode. This is a strictly sequential number used by Android to determine if one build is newer than another. If you upload an App Bundle with version code 4, your next update must be 5 or higher. If you forget to increment this integer, the Google Play Console will reject your upload.

Generating a Release Keystore

Android requires all applications to be digitally signed with a cryptographic certificate before they can be installed. For production, you must create a private keystore. This file proves your identity as the developer and prevents malicious actors from publishing fake updates to your app.

You can generate a keystore using the keytool utility, which is bundled with the Java Development Kit (JDK). Open your terminal and run the following command. On macOS and Linux:

keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload

If you are using Windows, the command structure is slightly different to accommodate the file system:

keytool -genkey -v -keystore "%USERPROFILE%\upload-keystore.jks" -keyalg RSA -keysize 2048 -validity 10000 -alias upload

The tool will prompt you to create a keystore password, answer several identification questions (like your name and organisation), and finally create a key password. Keep these passwords safe; you will need them in the next step. The -validity 10000 flag ensures your certificate remains valid for roughly 27 years, comfortably exceeding the lifespan of most applications.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Linking the Keystore with key.properties

Hardcoding your keystore passwords directly into your build scripts is a massive security risk, especially if your code is hosted on a platform like GitHub. Instead, Flutter relies on a local properties file to inject these credentials during the build process.

Create a new file named key.properties inside the android/ directory of your Flutter project. Add the following lines, replacing the placeholder values with your actual passwords and the absolute file path to the .jks file you just generated:

storePassword=your_secure_store_password
keyPassword=your_secure_key_password
keyAlias=upload
storeFile=/Users/username/upload-keystore.jks

Crucially, you must prevent this file from being committed to version control. Open your project's root .gitignore file and add the following rules to protect your signing credentials:

# Secure Android signing files
android/key.properties
*.jks

Configure build.gradle.kts for release signing

With your properties file in place, instruct Gradle to read these values and apply them to release builds. Current Flutter projects use the app-level Kotlin DSL file at android/app/build.gradle.kts.

Add these imports at the very top of the file, before the plugins block:

import java.io.FileInputStream
import java.util.Properties

Then put the property-loading declarations after the plugins block and before android { ... }:

val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}

Next, scroll down to the android { ... } block. You need to define a signingConfigs section and then bind it to the buildTypes release configuration. Update your file to match this structure:

android {
    // ... other configurations ...

    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
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
    }
}

Older projects may still have android/app/build.gradle; in that case, use the equivalent Groovy syntax rather than pasting Kotlin DSL into that file.

Targeting the Correct Android SDK

The Google Play Store enforces strict API level requirements. Every year, Google mandates that new apps and updates target a recent Android version to ensure compatibility with modern privacy and security features.

In a current project, open android/app/build.gradle.kts and inspect defaultConfig. These defaults come from the Flutter Gradle plug-in, not from values you should hand-edit in local.properties:

defaultConfig {
    applicationId = "com.yourcompany.appname"
    minSdk = flutter.minSdkVersion
    targetSdk = flutter.targetSdkVersion
    versionCode = flutter.versionCode
    versionName = flutter.versionName
}

If a dependency requires a higher minimum Android version, override minSdk in this Gradle block and document the devices you are dropping. Google Play's annual compatibility rule concerns targetSdk, whereas minSdk decides the oldest device that can install the app. Older projects may still use Groovy syntax in android/app/build.gradle, but the values belong in Gradle either way; do not try to redefine Flutter's SDK constants in local.properties.

Building the App Bundle for Google Play

Once your signing configuration is complete, generating the App Bundle is straightforward. Open your terminal at the root of your Flutter project and execute:

flutter build appbundle

Flutter will compile your Dart code ahead-of-time (AOT) for multiple architectures and package them into a single file. Because this process includes aggressive tree-shaking and compilation, it will take significantly longer than a standard debug build.

When the process finishes, you will find your deployment-ready file at build/app/outputs/bundle/release/app-release.aab. This is the exact file you will drag and drop into the Google Play Console under the "Production" or "Internal Testing" tracks.

Building Standalone APKs and Splitting ABIs

If you need an APK for external distribution, you might be tempted to run a basic flutter build apk --release. However, this command generates a "fat APK". A fat APK contains the compiled binaries for every possible CPU architecture (ARM32, ARM64, and x86_64). While convenient because it runs on any device, it results in a massive file size.

To optimise your standalone APKs, tell Flutter to split the build per Application Binary Interface (ABI):

flutter build apk --split-per-abi

This command outputs three separate, lightweight APKs in the build/app/outputs/flutter-apk/ directory:

  • app-armeabi-v7a-release.apk (for older 32-bit Android devices)
  • app-arm64-v8a-release.apk (for modern 64-bit Android devices, the most common target)
  • app-x86_64-release.apk (primarily for Android emulators and Chromebooks)

You can now distribute the specific APK that matches your tester's device, saving bandwidth and storage space.

Code Obfuscation and Debug Symbols

Because Flutter compiles Dart into native machine code, reverse engineering your exact source code is already difficult. However, class names, function names, and string literals remain visible in the compiled binary. To further protect your intellectual property, you should obfuscate your code.

Obfuscation scrambles your identifiers into meaningless characters. In Flutter, you must use the --obfuscate flag alongside the --split-debug-info flag. The latter tells Flutter where to save the mapping files needed to translate obfuscated crash logs back into readable code.

flutter build appbundle --obfuscate --split-debug-info=build/app/outputs/symbols

If your application crashes in production, tools like Firebase Crashlytics or the Google Play Console will show unreadable stack traces. You must upload the generated symbol files from the build/app/outputs/symbols directory to these platforms to de-obfuscate the errors and identify the root cause.

Gate debug logs by build mode

A common mistake developers make is leaving print() statements scattered throughout their code. In a release build, these logs are still evaluated and can leak sensitive information (like authentication tokens or API responses) to anyone viewing the device's system logs via logcat.

To keep this debug-only call out of profile and release builds, use Flutter's kDebugMode constant from the foundation package:

import 'package:flutter/foundation.dart';

void secureLog(String message) {
  if (kDebugMode) {
    debugPrint('DEBUG: $message');
  }
}

debugPrint() is not muted automatically in release builds; it is a throttled console writer, not a build-mode gate. Keep it behind a compile-time constant such as kDebugMode, as above, or configure a logging package with an explicit production level. Because kDebugMode is constant, release and profile compilation can remove the guarded debug branch.

Handling Google Play App Signing

When you create a new application in the Google Play Console, Google enables "Play App Signing" by default. This means the keystore you generated earlier is actually treated as an Upload Key.

You sign your App Bundle with your Upload Key, and Google Play verifies it upon upload. Once verified, Google strips your signature and re-signs the application with a highly secure App Signing Key stored on their servers. This is a massive safety net: if you ever lose your upload-keystore.jks file, you can contact Google Developer Support to reset it. If you were managing the final App Signing Key yourself and lost it, you would permanently lose the ability to update your app.

Ensure you back up your .jks file securely, but rest easy knowing that the modern Play Store infrastructure protects your application's long-term upgrade path.

Further reads

Keep going with these related tutorials from this site.