Nix-Wrapped Android Release Builds with Out-of-Repo Signing

5 min read

Android release builds have three annoyances that like to get tangled together:

  1. The Android SDK is huge, version-sensitive, and annoying to install consistently.
  2. Release signing needs a keystore and credentials that absolutely should not live in the repo.
  3. Flutter build commands tend to accrete tribal shell setup until nobody remembers what is actually required.

Nix solves the first and third problems nicely. For the second, the pattern I like is simple: keep signing material under ~/.config/your-app/, and have the flake symlink it into the place Gradle expects at build time.

That gives you a release workflow where:

  • the SDK version is pinned
  • the build command is repeatable
  • the signing config stays outside git
  • new machines bootstrap from nix develop instead of a README full of imperative setup

Here’s the pattern.

The shape of it

The repo stays clean. Secrets live in your home directory:

~/.config/myapp/
├── android-signing.properties
└── upload.jks

myapp/
├── flake.nix
├── android/
│   ├── .gitignore
│   └── app/build.gradle.kts
└── ...

android-signing.properties contains the passwords and points at the keystore using an absolute path. The repo never stores either file. The build script links the properties file into android/key.properties right before running the release build.

That one decision removes most of the risk. You stop playing games with encrypted blobs in app repos, and Gradle still gets the file layout it expects.

Step 1: pin the Android SDK in the flake

The core idea is to compose the Android SDK declaratively in flake.nix, then export the usual Android environment variables from the dev shell:

devShells = forAllSystems (system:
  let
    pkgs = import nixpkgs {
      inherit system;
      config = {
        allowUnfree = true;
        android_sdk.accept_license = true;
      };
    };

    androidComposition =
      pkgs.androidenv.composeAndroidPackages {
        buildToolsVersions = [ "28.0.3" "35.0.0" ];
        platformVersions = [ "36" "35" "34" "33" ];
        includeNDK = true;
        ndkVersions = [ "28.2.13676358" ];
        cmakeVersions = [ "3.22.1" ];
        includeEmulator = true;
        includeSystemImages = true;
        abiVersions = [ "arm64-v8a" "x86_64" ];
        systemImageTypes = [ "google_apis" ];
        includeSources = false;
      };

    androidSdk = androidComposition.androidsdk;
  in {
    default = pkgs.mkShellNoCC {
      packages = [
        pkgs.flutter
        androidSdk
        pkgs.jdk17
        release-android
      ];

      shellHook = ''
        export ANDROID_HOME="${androidSdk}/libexec/android-sdk"
        export ANDROID_SDK_ROOT="$ANDROID_HOME"
        export JAVA_HOME="${pkgs.jdk17}"
      '';
    };
  });

The important bits:

  • android_sdk.accept_license = true has to be set in config, or the SDK composition fails
  • ANDROID_HOME, ANDROID_SDK_ROOT, and JAVA_HOME should all be exported explicitly
  • buildToolsVersions, platformVersions, ndkVersions, and cmakeVersions should be pinned instead of left implicit

This is the whole point of using Nix here. You are replacing “install whatever Android Studio pulls today” with a concrete, reviewable SDK definition.

Step 2: keep signing config outside the repo

Under ~/.config/myapp/, create a properties file like this:

storePassword=your-store-password
keyPassword=your-key-password
keyAlias=upload
storeFile=/Users/yourname/.config/myapp/upload.jks

Then generate the keystore once:

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

Two details matter here:

  • storeFile should be an absolute path
  • both files should live somewhere user-private, not under the project tree

You can move these wherever you like, but ~/.config/myapp/ is an easy convention to remember and document.

Step 3: let Gradle read key.properties if it exists

On the Gradle side, the app should load android/key.properties when present and define the release signing config from it.

In android/app/build.gradle.kts:

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

val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")

if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}

android {
    signingConfigs {
        if (keystorePropertiesFile.exists()) {
            create("release") {
                keyAlias = keystoreProperties["keyAlias"] as String
                keyPassword = keystoreProperties["keyPassword"] as String
                storeFile = file(keystoreProperties["storeFile"] as String)
                storePassword = keystoreProperties["storePassword"] as String
            }
        }
    }

    buildTypes {
        release {
            signingConfig = if (keystorePropertiesFile.exists()) {
                signingConfigs.getByName("release")
            } else {
                signingConfigs.getByName("debug")
            }
        }
    }
}

And in android/.gitignore:

key.properties

The fallback to debug signing is optional, but useful. It means the project still builds for local testing even when the real release credentials are absent.

Step 4: wrap the release build in Nix

Now the useful part: hide the shell setup and symlink dance behind a single command.

I like doing this with writeShellScriptBin:

release-android =
  pkgs.writeShellScriptBin "release-android" ''
    set -euo pipefail

    export ANDROID_HOME="${androidSdk}/libexec/android-sdk"
    export ANDROID_SDK_ROOT="$ANDROID_HOME"
    export JAVA_HOME="${pkgs.jdk17}"

    root="$(${pkgs.git}/bin/git rev-parse --show-toplevel 2>/dev/null || pwd)"
    signing="$HOME/.config/myapp/android-signing.properties"

    if [ ! -f "$signing" ]; then
      echo "error: signing config not found" >&2
      echo >&2
      echo "Create $signing with:" >&2
      echo "  storePassword=<your store password>" >&2
      echo "  keyPassword=<your key password>" >&2
      echo "  keyAlias=upload" >&2
      echo "  storeFile=/absolute/path/to/keystore.jks" >&2
      exit 1
    fi

    ln -sf "$signing" "$root/android/key.properties"
    echo "linked signing config"

    cd "$root"
    flutter clean
    flutter pub get
    flutter build appbundle --release \
      --dart-define=GRPC_HOST=api.prod.example.com \
      --dart-define=GRPC_PORT=443 \
      --dart-define=GRPC_USE_TLS=true \
      "$@"

    echo
    echo "Bundle: build/app/outputs/bundle/release/app-release.aab"
  '';

This script does four things:

  1. Exports the Android and Java paths expected by Flutter and Gradle.
  2. Verifies that the signing config exists before doing any expensive work.
  3. Symlinks the external properties file into android/key.properties.
  4. Runs the release build with whatever --dart-define values production needs.

That is a much better interface than “first open the right shell, then export three variables, then remember the path to the signing file, then run the exact build incantation.”

It also means CI or another developer can run the same command and get the same behaviour, assuming they have their own signing material configured.

Step 5: add emulator helpers while you’re here

Once you’re already pinning the SDK, adding emulator launchers is cheap and useful.

Here’s a small helper that creates an AVD only if it doesn’t already exist, then launches it:

abi = if pkgs.stdenv.hostPlatform.isAarch64
  then "arm64-v8a"
  else "x86_64";

mkEmulator = { name, avdName, device, apiLevel, desc }:
  pkgs.writeShellScriptBin name ''
    set -euo pipefail

    export ANDROID_HOME="${androidSdk}/libexec/android-sdk"
    export ANDROID_SDK_ROOT="$ANDROID_HOME"
    export PATH="${pkgs.jdk17}/bin:$PATH"

    AVD_NAME="${avdName}"
    SYSTEM_IMAGE="system-images;android-${apiLevel};google_apis;${abi}"

    if ! "$ANDROID_HOME/emulator/emulator" -list-avds 2>/dev/null \
         | grep -qx "$AVD_NAME"; then
      echo "Creating ${desc}..."
      echo "no" | "$ANDROID_HOME"/cmdline-tools/*/bin/avdmanager \
        create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" \
        -d "${device}" --force
    fi

    echo "Launching ${desc}..."
    exec "$ANDROID_HOME/emulator/emulator" -avd "$AVD_NAME" "$@"
  '';

emu-phone = mkEmulator {
  name = "emu-phone";
  avdName = "myapp_phone";
  device = "pixel_7";
  apiLevel = "35";
  desc = "Pixel 7 API 35";
};

emu-tablet = mkEmulator {
  name = "emu-tablet";
  avdName = "myapp_tablet";
  device = "pixel_tablet";
  apiLevel = "34";
  desc = "Pixel Tablet API 34";
};

The nice thing here is not just convenience. It’s that the emulator definition becomes part of the same reproducible environment as the SDK itself. Team members are not manually creating mystery AVDs with slightly different images and wondering why behaviour differs.

What usage looks like

From a clean machine, the workflow becomes:

# Enter the project environment
nix develop

# Run an emulator
emu-phone

# Build the signed Android app bundle
release-android

The result lands at:

build/app/outputs/bundle/release/app-release.aab

That’s the artifact you upload to Google Play.

Why this pattern is worth keeping

The main win is not “you can build Android with Nix.” You can do that a dozen messy ways. The win is that responsibilities get separated cleanly:

  • Nix owns SDK provisioning and wrapper scripts
  • Gradle owns signing configuration and release build logic
  • your home directory owns secrets
  • the repo stays free of machine-local credentials

That separation scales better than the usual alternative, where every developer has a slightly different Android setup and the release process is half shell history, half folklore.

If you’re already using flakes for your dev environment, Android release builds fit into that model surprisingly well. Put the SDK in the flake, put the signing material under ~/.config, wrap the build in one script, and stop treating release day as a special machine ceremony.