Shipping an Expo app without EAS: local, signed Android release builds

★ ★ ★ ★ ★ Not rated

Why skip EAS

The decision here was a simple one: no dependency on another service and its terms, and no payment for builds. EAS Build is a good product, but its Free plan allows 15 Android and 15 iOS builds per month and puts free builds in a low-priority queue. A local build has no quota, no queue and needs no account.

It also helps to be clear about what EAS actually does for a release. There are only four jobs, and each can be done locally:

EAS doesLocallySection
Creates and stores the keystorekeytool and a password manager2
Builds the native projectexpo prebuild and Gradle3, 5, 6
Signs the bundleA signing config injected by a config plugin4
Submits to the storeUpload the .aab in Play Console (or a service account, optional)8
When EAS is the better choice. If iOS certificates and provisioning profiles are new to you, EAS manages them for you. It also builds from any machine and fits CI well. If you have no Mac or no Android SDK to hand, or a team that needs reproducible cloud builds, use it. This guide is for the case where you have the machine and would rather own the pipeline.

Generate the upload keystore outside the repository

Create the keystore once, in a directory that is not part of any project:

mkdir -p ~/.keys && chmod 700 ~/.keys
keytool -genkeypair -v \
  -keystore ~/.keys/myapp-upload.jks -storetype PKCS12 \
  -alias upload -keyalg RSA -keysize 2048 -validity 10000 \
  -dname "CN=Your Name, O=Your Company, C=CZ"

Next to it, create a properties file that the build will read, and restrict it to your user:

# ~/.keys/myapp-keystore.properties
storeFile=/path/to/.keys/myapp-upload.jks
storePassword=...
keyAlias=upload
keyPassword=...
chmod 600 ~/.keys/myapp-keystore.properties ~/.keys/myapp-upload.jks
Back it up off this computer, today. Put the .jks and the passwords in a password manager and a second place such as a NAS or a cloud drive. Without the upload key you cannot publish updates. Play App Signing softens this: Google holds the real app signing key, so a lost upload key can be reset through Google support. That process is slow, and it is a rescue, not a backup strategy. Never commit any of these files.

Print the certificate fingerprints. The SHA-1 is what you will compare against in section 6 and what restricts API keys (for example a Google Maps key limited to your package name and signing certificate):

keytool -list -v -keystore ~/.keys/myapp-upload.jks -alias upload | grep -E "SHA1|SHA256"

The problem: android/ is generated

In a managed or Continuous Native Generation project, android/ is a build artifact. npx expo prebuild rewrites android/app/build.gradle, and the template signs the release build type with the debug keystore. If you edit the signing config by hand, it disappears the next time someone runs prebuild --clean, often just before a release.

The durable answer is a config plugin: code that runs during prebuild and patches the generated files the same way every time.

A config plugin for release signing

The plugin below does four things. It reads the properties path from an environment variable (default ~/.keys/myapp-keystore.properties), inserts a small Groovy block before android { that loads the file, adds signingConfigs.release after the debug config, and switches buildTypes.release to the release config when the properties file exists. If the file is missing, Gradle only prints a warning and signs with the debug key, so day-to-day development never breaks. A marker comment makes the patch idempotent.

// tools/withReleaseSigning.js
const { withAppBuildGradle, withGradleProperties } = require('expo/config-plugins');

const MARKER = 'MYAPP_RELEASE_SIGNING';

module.exports = function withReleaseSigning(config, props = {}) {
  const envName = props.propertiesEnv || 'MYAPP_KEYSTORE_PROPERTIES';
  const defaultPath = props.propertiesPath || '~/.keys/myapp-keystore.properties';

  // Raise the Gradle JVM limits (see section 5); survives prebuild.
  config = withGradleProperties(config, (cfg) => {
    const items = cfg.modResults.filter(
      (i) => !(i.type === 'property' && i.key === 'org.gradle.jvmargs')
    );
    items.push({
      type: 'property',
      key: 'org.gradle.jvmargs',
      value: '-Xmx4096m -XX:MaxMetaspaceSize=1024m',
    });
    cfg.modResults = items;
    return cfg;
  });

  return withAppBuildGradle(config, (cfg) => {
    let g = cfg.modResults.contents;
    if (g.includes(MARKER)) return cfg; // already patched

    // 1) Load the properties file before the `android {` block.
    const loader = `// ${MARKER}
def myappKeystorePath = System.getenv('${envName}') ?: '${defaultPath}'
if (myappKeystorePath.startsWith('~')) {
    myappKeystorePath = System.getProperty('user.home') + myappKeystorePath.substring(1)
}
def myappKeystoreFile = new File(myappKeystorePath)
def myappSigning = new Properties()
if (myappKeystoreFile.exists()) {
    myappKeystoreFile.withInputStream { myappSigning.load(it) }
} else {
    logger.warn("WARNING: keystore properties not found (" + myappKeystorePath + "); release will be signed with the DEBUG key and cannot be uploaded to Google Play.")
}

`;
    const androidIdx = g.search(/^android\s*\{/m);
    if (androidIdx < 0) throw new Error('no android { block in build.gradle');
    g = g.slice(0, androidIdx) + loader + g.slice(androidIdx);

    // 2) signingConfigs.release right after the debug config.
    const debugRe = /(signingConfigs\s*\{\s*debug\s*\{[^}]*\}\s*)/;
    if (!debugRe.test(g)) throw new Error('signingConfigs.debug not found');
    g = g.replace(debugRe, `$1if (myappKeystoreFile.exists()) {
            release {
                storeFile file(myappSigning['storeFile'])
                storePassword myappSigning['storePassword']
                keyAlias myappSigning['keyAlias']
                keyPassword myappSigning['keyPassword']
            }
        }
        `);

    // 3) Use it in buildTypes.release only; leave debug alone.
    const relRe = /(buildTypes\s*\{[\s\S]*?\brelease\s*\{[\s\S]*?)signingConfig signingConfigs\.debug/;
    if (!relRe.test(g)) throw new Error('signingConfig not found in buildTypes.release');
    g = g.replace(relRe,
      `$1signingConfig myappKeystoreFile.exists() ? signingConfigs.release : signingConfigs.debug`);

    cfg.modResults.contents = g;
    return cfg;
  });
};

Register it in app.config.js, a dynamic config that wraps the static app.json:

// app.config.js
module.exports = ({ config }) => ({
  ...config,
  plugins: [
    ...(config.plugins ?? []),
    [require('./tools/withReleaseSigning'), { propertiesEnv: 'MYAPP_KEYSTORE_PROPERTIES' }],
  ],
});
The plugin throws on purpose. If a future Expo template changes the shape of build.gradle, a failing prebuild with a clear message is far better than a bundle that is silently signed with the debug key. Section 6 adds a second line of defence by checking the fingerprint.

Gradle Metaspace crash

Symptom. The first bundleRelease ran for about 100 seconds and then died with java.lang.OutOfMemoryError: Metaspace.

Cause. The Gradle daemon's default MaxMetaspaceSize is 512 MB, which is not enough for the release pipeline (R8 and the React Native Gradle plugin).

Fix. In android/gradle.properties:

org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m

Because android/ is regenerated, this is set by the same plugin through withGradleProperties (already in the code above), so it survives prebuild --clean.

Build and verify

Tested on Expo SDK 57 with React Native 0.86, Node 24, JDK 21 and Gradle 9:

export ANDROID_HOME=$HOME/Library/Android/sdk
npx expo prebuild -p android --no-install     # add --clean only when you must
cd android && ./gradlew bundleRelease -x lint -x test

On an Apple Silicon Mac the build takes about 7 minutes. The result is a bundle of roughly 76 MB at android/app/build/outputs/bundle/release/app-release.aab.

Verify who signed it, and compare against the SHA-1 you printed in section 2:

keytool -printcert -jarfile android/app/build/outputs/bundle/release/app-release.aab
jarsigner -verify android/app/build/outputs/bundle/release/app-release.aab
Expected result The certificate owner is the one from your -dname, the SHA1 equals the upload key's, and jarsigner reports "jar verified". A debug-signed bundle shows CN=Android Debug.

Versions

Set all three explicitly in app.json so a release is never ambiguous:

{ "expo": {
    "version": "1.0.0",
    "android": { "versionCode": 1 },
    "ios": { "buildNumber": "1" }
} }

Play rejects an upload whose versionCode it has already seen, so increase it by one by hand for every release.

A release script

Steps 2 to 6 fit in one script that stops at the first problem. It checks the properties file, prebuilds, builds, compares the fingerprint with the expected one and copies the bundle to the desktop with the version in its name:

#!/usr/bin/env bash
# tools/release-android.sh [--clean]
set -euo pipefail

EXPECTED_SHA1="AA:BB:CC:...:ZZ"   # SHA-1 of your upload key (public information)
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"

export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
PROPS="${MYAPP_KEYSTORE_PROPERTIES:-$HOME/.keys/myapp-keystore.properties}"
[ -f "$PROPS" ] || { echo "Missing $PROPS - release would be debug-signed." >&2; exit 1; }

CLEAN=""
[ "${1:-}" = "--clean" ] && CLEAN="--clean"

# The signing plugin only takes effect during prebuild, so always run it.
CI=1 EXPO_OFFLINE=1 npx expo prebuild -p android $CLEAN --no-install

(cd android && ./gradlew bundleRelease -x lint -x test)

AAB="$ROOT/android/app/build/outputs/bundle/release/app-release.aab"
[ -f "$AAB" ] || { echo "No bundle: $AAB" >&2; exit 1; }

ACTUAL="$(keytool -printcert -jarfile "$AAB" | awk '/SHA1:/ {print $2; exit}')"
if [ "$ACTUAL" != "$EXPECTED_SHA1" ]; then
  echo "Signature mismatch! Expected $EXPECTED_SHA1, got ${ACTUAL:-<none>}" >&2
  exit 1
fi

VERSION="$(node -p "require('./app.json').expo.version")"
OUT="$HOME/Desktop/myapp-$VERSION-release.aab"
cp "$AAB" "$OUT"
echo "Done: $OUT (SHA1 $ACTUAL)"

Wire it into package.json:

"scripts": { "release:android": "bash tools/release-android.sh" }
Environment variables for plugins. If the app uses Google Maps through react-native-maps, export GOOGLE_MAPS_ANDROID_API_KEY before running the script. The plugin writes it into the manifest during prebuild, so a missing variable produces a bundle with a map that stays blank.

Upload to Google Play by hand

In Play Console open Testing → Internal testing → Create new release and upload the .aab. The first upload enrols the app in Play App Signing: Google derives its own app signing key and re-signs what users download. That key has a different SHA-1, which you can copy from Protect with Play → App signing (formerly App integrity). The whole Play Console flow is in Publishing an Android app on Google Play.

Add the Play signing SHA-1 to restricted API keys. If your Maps key is restricted to your package name and signing certificate, add the Play App Signing SHA-1 as well as the upload key's. Otherwise maps work in your local build and are blank for testers.

A service account for automated uploads is optional. It is configured in Google Cloud and Play Console, and has nothing to do with EAS. For a few releases a year, uploading by hand takes a minute.

What about iOS

Not yet verified by us. Sections 2 to 7 were run end to end, and section 8 follows the Play Console flow. The iOS path below is the expected route from the documentation, not something we have shipped yet.
  • npx expo prebuild -p ios to generate the Xcode project.
  • Open the workspace in Xcode, enable automatic signing and select your team.
  • Product → Archive, then Distribute App → App Store Connect.
  • On Xcode 27, enable scene support with expo-build-properties (ios.enableSceneSupport: true), otherwise the app crashes at launch. Details in Expo SDK 57 on Xcode 27: a field guide.

Checklist

  • Upload keystore and properties file live in ~/.keys/, permissions 600, never in the repository.
  • Keystore and passwords backed up in two places away from the computer.
  • SHA-1 of the upload key recorded, and used in the release script.
  • Signing and org.gradle.jvmargs are set by a config plugin, not by editing android/.
  • ANDROID_HOME set; GOOGLE_MAPS_ANDROID_API_KEY exported if the app uses Maps.
  • version, versionCode and buildNumber bumped in app.json.
  • keytool -printcert shows the expected SHA-1 and jarsigner -verify passes.
  • First upload done through Internal testing; Play App Signing SHA-1 added to restricted API keys.
0.0
★ ★ ★ ★ ★
0 reviews
5 ★
0 %
4 ★
0 %
3 ★
0 %
2 ★
0 %
1 ★
0 %

Add a comment

There's nothing here.

No comments have been posted here yet.