skip to content

How do you configure release signing for a Flutter Android app using an upload keystore, key.properties and signingConfigs in build.gradle.kts?

level: middleimportance: must knowfreq 55%

answer

  1. template signs release with the debug key
  2. keytool creates upload-keystore.jks
  3. four keys in key.properties
  4. create("release") in signingConfigs
  5. keep both files out of git

basics

~20 s

Create an upload keystore with keytool, describe it in android/key.properties (storePassword, keyPassword, keyAlias, storeFile), load that file in android/app/build.gradle.kts, create a release signingConfig from it, and point the release build type at it instead of the debug key.

solid answer

~30 s

A new Flutter project's `build.gradle.kts` signs the release build type with `signingConfigs.getByName("debug")` so `flutter run --release` works, but Play rejects debug-signed uploads. You generate an upload keystore with `keytool -genkey ... -alias upload`, write `android/key.properties` with `storePassword`, `keyPassword`, `keyAlias` and `storeFile`, and at the top of `android/app/build.gradle.kts` load it into a `java.util.Properties`. Inside `android {}` you add `signingConfigs { create("release") { ... } }` reading those four values, and set `signingConfig = signingConfigs.getByName("release")` in `buildTypes.release`. Neither the keystore nor `key.properties` goes into version control; CI recreates them from secrets. After changing signing, `flutter clean` avoids stale cached builds.

code

kotlin · 32 lines
kotlin
import java.io.FileInputStream
import java.util.Properties

plugins {
    id("com.android.application")
    id("dev.flutter.flutter-gradle-plugin")
}

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

android {
    namespace = "com.example.plantcare"

    signingConfigs {
        create("release") {
            keyAlias = keystoreProperties.getProperty("keyAlias")
            keyPassword = keystoreProperties.getProperty("keyPassword")
            storeFile = keystoreProperties.getProperty("storeFile")?.let { file(it) }
            storePassword = keystoreProperties.getProperty("storePassword")
        }
    }

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

go deeper

for a junior

Recall the pieces: keytool makes the keystore, key.properties describes it, build.gradle.kts turns it into a release signing config.

for a middle

Explain why the template signs release with the debug key, how the properties are loaded before the android block, and what stays out of git.

for a senior

Run signing reproducibly in CI from secrets, back up the upload key, and diagnose debug-signed and wrong-certificate rejections quickly.

for a principal

Own key custody: who can access the upload key, where it is backed up, and how a reset or rotation is handled without blocking releases.

## Why this step exists Every Android app must be **signed** with a certificate before it can be installed or published. For Google Play there are two keys: the **upload key**, which you hold and sign uploads with, and the **app signing key**, which Play holds and uses to sign what users install. This question is about the upload key and how a Flutter project's Gradle build uses it. The starting point matters: a fresh Flutter project's `android/app/build.gradle.kts` contains ```kotlin buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") } } ``` That makes release builds runnable locally, but an upload signed with the debug key is rejected by Google Play with an error saying it was signed in debug mode. ## Step 1: create an upload keystore The Flutter docs use the JDK's `keytool`: ```bash keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -storetype JKS -keysize 2048 -validity 10000 -alias upload ``` `keytool` ships with the JDK bundled with Android Studio; `flutter doctor -v` prints the Java binary's path if it is not on your `PATH`. The file lives **outside the project** by default. ## Step 2: describe it in `key.properties` Create `android/key.properties`: ```properties storePassword=<store password> keyPassword=<key password> keyAlias=upload storeFile=/Users/<you>/upload-keystore.jks ``` On Windows the path needs doubled backslashes. The file is a plain Java properties file, read only by Gradle. ## Step 3: load it and create the signing config In `android/app/build.gradle.kts`: 1. Import `java.util.Properties` and `java.io.FileInputStream` and load the file **before** the `android {}` block, guarded by `exists()` so machines without it can still build debug. 2. Add a `signingConfigs` block that creates a `release` config from the four properties. 3. Point `buildTypes.release.signingConfig` at it. ```kotlin signingConfigs { create("release") { keyAlias = keystoreProperties.getProperty("keyAlias") keyPassword = keystoreProperties.getProperty("keyPassword") storeFile = keystoreProperties.getProperty("storeFile")?.let { file(it) } storePassword = keystoreProperties.getProperty("storePassword") } } ``` Now `flutter build appbundle` produces a bundle signed with the upload key. The Flutter guide notes that you may need `flutter clean` after changing the Gradle file so cached outputs do not mask the change. ## Keeping it secret, and CI - **Never commit** the keystore or `key.properties`; add both to `.gitignore`. Anyone with the keystore and passwords can sign uploads as you. - **CI** writes both files from its secret store before the build (for example, a base64-encoded keystore decoded to disk) and deletes them afterwards. How secrets are stored and injected is the pipeline's concern. - **Back up** the keystore and passwords in a password manager or vault; losing them means requesting an upload-key reset. | File | Contains | Committed? | |---|---|---| | `upload-keystore.jks` | the private key and certificate | never | | `android/key.properties` | passwords, alias, keystore path | never | | `android/app/build.gradle.kts` | code that reads the properties | yes | ## Common failures - **Play rejects the upload as debug-signed** — the release build type still points at `signingConfigs.getByName("debug")`. - **Release build fails with a missing `storeFile`** — `key.properties` is absent on that machine (often CI), so `storeFile` resolved to `null`. - **Play reports the wrong upload certificate** — the bundle was signed with a different keystore than the one registered as the upload key. - **Wrong alias or password** — `keyAlias` must match the `-alias` used with `keytool`. ## Verifying the result Before uploading, confirm what actually signed the artifact. For a bundle, the JDK's `keytool -printcert -jarfile app.aab` prints the signing certificate and its SHA-256 fingerprint (bundles are signed like JAR files); for an APK, the Android build tools' `apksigner verify --print-certs` does the same. Compare it with the upload certificate registered in Play. A mismatch caught locally is cheaper than a rejected upload, and the same check in CI catches a job that silently fell back to the debug key. ## Flavors and signing When an app has product flavors, each flavor can use its own signing config by setting `signingConfig` inside the flavor, for example when a white-label build ships under a different Play listing. Most apps with staging and production flavors sign both with the same upload key and differ only in application ID.

  • Why does flutter run --release work on a new project before any keystore exists?
    The template's release build type uses `signingConfigs.getByName("debug")`, so release builds are signed with the local debug key. That is fine for running on a device but not for Play, which rejects debug-signed uploads.
  • Why guard the key.properties load with exists() instead of loading it unconditionally?
    Developers and CI jobs that only build debug do not have the file. With the guard, Gradle configures fine and only a release signing attempt fails for lack of a keystore; without it, every Gradle invocation on those machines fails while reading a missing file.

saying these in an interview costs you the question

  • Commits the keystore or key.properties to the repository
  • Uploads a release built with the template's debug signing config
  • Stores the keystore only on one laptop with no backup
  • Thinks key.properties is read by the Flutter tool rather than Gradle
  • Uses a different keystore per CI run for the same Play app