Shipping an Expo app without EAS: local, signed Android release builds
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 does | Locally | Section |
|---|---|---|
| Creates and stores the keystore | keytool and a password manager | 2 |
| Builds the native project | expo prebuild and Gradle | 3, 5, 6 |
| Signs the bundle | A signing config injected by a config plugin | 4 |
| Submits to the store | Upload the .aab in Play Console (or a service account, optional) | 8 |
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.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' }],
],
});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=1024mBecause 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 testOn 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-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" }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.
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
npx expo prebuild -p iosto 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.jvmargsare set by a config plugin, not by editingandroid/. ANDROID_HOMEset;GOOGLE_MAPS_ANDROID_API_KEYexported if the app uses Maps.version,versionCodeandbuildNumberbumped inapp.json.keytool -printcertshows the expected SHA-1 andjarsigner -verifypasses.- First upload done through Internal testing; Play App Signing SHA-1 added to restricted API keys.
There's nothing here.
No comments have been posted here yet.