Expo Android Production Deployment with Local Builds
A guide for deploying an Expo React Native Android app to Google Play without relying on EAS Build. This covers switching to a production config, setting up signing with a config plugin that survives expo prebuild --clean, and building the release AAB locally with Gradle.
Prerequisites
- Android Studio with SDK installed
- An Expo project with a working development build (prebuild already run)
- Firebase project set up for production
- A Google Play Console developer account
1. Update Package Name
In app.json, change the Android package to your production value:
{
"expo": {
"android": {
"package": "com.yourcompany.yourapp",
"googleServicesFile": "./google-services.json"
}
}
}
Then run npx expo prebuild --platform android to sync the change to build.gradle.
2. Set Up google-services.json
Download google-services.json from your production Firebase project. Make sure the package_name inside it matches your production package name. The file contains public config only (project IDs, API keys, OAuth client IDs) — it’s safe to commit to git.
3. Generate an Upload Keystore
Google Play requires your AAB to be signed. Generate a new keystore in your project root (not inside android/, since prebuild --clean wipes that folder):
keytool -genkeypair -v \
-keystore android-upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias upload
It will prompt for a password and identity info. Remember the password and alias — you’ll need them.
To verify the keystore later:
keytool -list -v -keystore android-upload-keystore.jks -alias upload
Important: Back up this file somewhere safe, and add it to .gitignore:
# .gitignore
*.jks
*.keystore
.env.android
4. Configure Signing with a Config Plugin
Manually editing android/app/build.gradle won’t survive npx expo prebuild --clean. Instead, use a config plugin that injects the release signing config automatically.
Create .env.android in your project root (gitignored) with your signing credentials:
ANDROID_RELEASE_STORE_PASSWORD=your_password_here
ANDROID_RELEASE_KEY_ALIAS=upload
ANDROID_RELEASE_KEY_PASSWORD=your_password_here
Create a template file gradle.properties.example (committed) so other developers know which keys are needed:
# Release signing config
# Create .env.android with actual values
ANDROID_RELEASE_STORE_PASSWORD=
ANDROID_RELEASE_KEY_ALIAS=upload
ANDROID_RELEASE_KEY_PASSWORD=
Create the config plugin at plugins/withAndroidSigningConfig.js:
const {
withAppBuildGradle,
withDangerousMod,
} = require("expo/config-plugins");
const fs = require("fs");
const path = require("path");
function withAndroidSigningConfig(config) {
// Step 1: Modify build.gradle to add release signing config
config = withAppBuildGradle(config, (config) => {
let contents = config.modResults.contents;
// Add release signing config block if missing
if (!contents.includes("signingConfigs.release")) {
contents = contents.replace(
/signingConfigs\s*\{[^}]*debug\s*\{[^}]*\}\s*\}/,
(match) =>
match.replace(
/\}(\s*)\}/,
`}$1 release {` +
`$1 storeFile file('release.jks')` +
`$1 storePassword project.findProperty('ANDROID_RELEASE_STORE_PASSWORD') ?: ''` +
`$1 keyAlias project.findProperty('ANDROID_RELEASE_KEY_ALIAS') ?: 'upload'` +
`$1 keyPassword project.findProperty('ANDROID_RELEASE_KEY_PASSWORD') ?: ''` +
`$1 }$1}`,
),
);
}
// Ensure release buildType uses release signing config
contents = contents.replace(
/(buildTypes\s*\{[\s\S]*?release\s*\{[\s\S]*?)signingConfig\s+signingConfigs\.debug/,
"$1signingConfig signingConfigs.release",
);
config.modResults.contents = contents;
return config;
});
// Step 2: Copy keystore and signing properties into android/
config = withDangerousMod(config, [
"android",
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const androidAppDir = path.join(projectRoot, "android", "app");
const androidDir = path.join(projectRoot, "android");
// Copy keystore to android/app/
const keystoreSrc = path.join(projectRoot, "android-upload-keystore.jks");
const keystoreDest = path.join(androidAppDir, "release.jks");
if (fs.existsSync(keystoreSrc) && !fs.existsSync(keystoreDest)) {
fs.copyFileSync(keystoreSrc, keystoreDest);
}
// Append .env.android contents to android/gradle.properties
const envFile = path.join(projectRoot, ".env.android");
const gradleProps = path.join(androidDir, "gradle.properties");
if (fs.existsSync(envFile) && fs.existsSync(gradleProps)) {
const existing = fs.readFileSync(gradleProps, "utf8");
if (!existing.includes("ANDROID_RELEASE_STORE_PASSWORD")) {
const envContent = fs.readFileSync(envFile, "utf8");
fs.appendFileSync(
gradleProps,
"\n# Release signing config (from .env.android)\n" + envContent,
);
}
}
return config;
},
]);
return config;
}
module.exports = withAndroidSigningConfig;
Register the plugin in app.json:
{
"expo": {
"plugins": [
"./plugins/withAndroidSigningConfig",
...
]
}
}
Now run npx expo prebuild --platform android and verify that android/app/build.gradle has the release signing config injected.
5. Register SHA-1 in Firebase
Your signing key’s SHA-1 fingerprint must be registered in Firebase, otherwise services like Google Sign-In will fail with DEVELOPER_ERROR.
Get the SHA-1:
keytool -list -v -keystore android-upload-keystore.jks -alias upload
Look for the SHA1: line under “Certificate fingerprints”. Then go to Firebase Console → Project Settings → Your Android app → Add fingerprint and paste it.
If you also test on emulators with debug builds, add the debug keystore’s SHA-1 too:
keytool -list -v -keystore android/app/debug.keystore -alias androiddebugkey -storepass android
After adding fingerprints, re-download google-services.json and replace the one in your project.
6. Build the AAB
cd android
./gradlew bundleRelease
The output AAB will be at:
app/build/outputs/bundle/release/app-release.aab
Upload this to Google Play Console.
Why Local Builds Instead of EAS Build?
EAS Build works great for standalone projects, but in a monorepo setup, you might hit issues with package resolution — local workspace packages may not resolve correctly during the EAS Build process. Building locally with Gradle sidesteps this entirely since it uses your local node_modules directly.
Common Pitfalls
prebuild --cleanwipes signing config: This is why we use a config plugin instead of manually editingbuild.gradle. The plugin re-injects the config on every prebuild.- Keystore in
android/gets deleted: Keep the keystore in the project root and let the plugin copy it intoandroid/app/. - SHA-1 mismatch: The most common cause of
DEVELOPER_ERRORon Google Sign-In. Always register your signing key’s SHA-1 in Firebase Console. - Debug vs release keystore: Emulators use the debug keystore by default. If Google Sign-In works in release but not debug (or vice versa), you’re missing a SHA-1 fingerprint for that build type.
- Wrong signing key on AAB: If Play Console rejects your AAB saying it’s signed with the wrong key, check that
build.gradlehassigningConfig signingConfigs.release(not.debug) underbuildTypes.release.