Nix-Wrapped Android Release Builds with Out-of-Repo Signing
5 min readAndroid release builds have three annoyances that like to get tangled together:
- The Android SDK is huge, version-sensitive, and annoying to install consistently.
- Release signing needs a keystore and credentials that absolutely should not live in the repo.
- 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 developinstead 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 = truehas to be set inconfig, or the SDK composition failsANDROID_HOME,ANDROID_SDK_ROOT, andJAVA_HOMEshould all be exported explicitlybuildToolsVersions,platformVersions,ndkVersions, andcmakeVersionsshould 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:
storeFileshould 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:
- Exports the Android and Java paths expected by Flutter and Gradle.
- Verifies that the signing config exists before doing any expensive work.
- Symlinks the external properties file into
android/key.properties. - Runs the release build with whatever
--dart-definevalues 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.