I was fixing a bug in Nudge, our app for people who inflate small tasks into big ones. The bug only showed up on a real device, so I kept building, installing, and testing over and over. Each build ran on EAS, Expo's cloud build service, and each one used up part of my monthly build limit. By the time the bug was fixed, I had run out of builds for the month.
That was a problem, because I needed to ship the fix. It was ready for production, but my EAS builds were gone until the quota reset.

I had two choices:
1. Build it locally — install the Android toolchain on my machine, wrangle Java versions, keep the keystore on my laptop, and run the build by hand every time.
2. Build it in a CI/CD pipeline — hand the whole job to a fresh GitHub Actions runner that builds, signs, and hands back a production `.aab`.
I went with the second option, and haven't worried about the quota since. This post is that setup, start to finish. The trick is that EAS can build locally (on any machine, including a CI runner) instead of on its cloud builders, so these builds don't touch your monthly quota at all. GitHub Actions runs the build, EAS handles the Expo/React Native toolchain, and your signing keys ride along safely in GitHub Secrets. If you've got an Expo project and a keystore, you can have this working in an afternoon.
What you'll need before starting
- An Expo / React Native project
- The EAS CLI (
`npm install -g eas-cli`) - Your signed Android keystore (the `.jks` file) and its passwords
- An Expo account, and an `EXPO_TOKEN` from your [expo.dev] (https://expo.dev) account settings
That's it. Everything else gets set up below.
Why not just build locally?
Building on your own machine works, and if your Android environment is already set up, it's fine. The catch is that setup. If you don't already have Android tooling installed, a local build means installing the Android SDK and a JDK, then getting both to the exact versions your build expects, the wrong Java version alone can send you down a rabbit hole. That configuration can eat more time than the build itself, and it's time you spend before you even getting a .aab file. On top of that, the keystore has to live somewhere on your disk.
It gets worse on Windows. The command at the heart of this post eas build --local — isn't supported on native Windows at all. EAS only runs local builds on macOS and Linux. So on a Windows machine you first have to set up WSL2 , an Ubuntu environment living inside Windows and then install the Android SDK and JDK inside that Linux box before you can build. That's a whole extra layer of setup on top of everything above.
The CI/CD route makes all of that setup someone else's problem. You describe the environment once in a YAML file, and GitHub builds it fresh on every run, the right Node, the right JDK, the SDK, all pinned to versions you chose. The idea is simple; GitHub gives you a clean Ubuntu box on demand, EAS turns your React Native project into a signed Android App Bundle locally on that box (so no cloud build credits are spent), and GitHub Secrets holds the keystore so it never lives on a laptop or in the repo. The workflow just wires those three together — and because the runner is disposable, you get an identical, reproducible environment every single time, on hardware you never had to configure.
Let's walk through it.
Step 1: Point EAS at local credentials
By default, EAS resolves signing credentials from Expo's servers, its docs are explicit that "if you do not set any option, credentialsSource will default to remote." For CI we want the opposite, sign with a credentials.json file the workflow generates on the runner. You flip that with one line in eas.json (the config file at the root of your Expo project, next to package.json). Find your production profile and add "credentialsSource": "local" under the android block:
"build": {
"production": {
"android": {
"buildType": "app-bundle",
"credentialsSource": "local"
}
}
}
}Leave everything else in the file alone — your cli block, other profiles (development, preview), and any existing keys stay as they are. You're only adding the one line inside build.production.android. Without it, EAS ignores the credentials.json we're about to create and reaches for remote credentials instead.
Step 2: Turn your keystore into a string
The runner needs your keystore to sign the build, but GitHub Secrets only stores strings — and a .jks keystore is a binary file. So we base64-encode it into one long line of text that we can paste into a secret.
On Windows (PowerShell):
[Convert]::ToBase64String([IO.File]::ReadAllBytes("path\to\keystore.jks")) | Set-ClipboardOn macOS / Linux:
base64 -w 0 keystore.jks | pbcopy # macOS
base64 -w 0 keystore.jks # Linux — copy the output manuallyThe one gotcha that will bite you: that base64 string must be a single line. The -w 0 flag (and the PowerShell method above) guarantees no line wrapping. If your string has newlines in it, the decode step in CI fails with a cryptic base64: invalid input , and you'll spend twenty minutes convinced the problem is your keystore. It isn't. It's the newlines.
Step 3: Add the secrets to GitHub
In your repo, go to Settings → Secrets and variables → Actions → New repository secret, and add these five:
Secret Name | Value |
|---|---|
EXPO_TOKEN | Your Expo access token from expo.dev → Settings → Access tokens |
ANDROID_KEYSTORE_BASE64 | The base64 string from Step 2 |
ANDROID_KEYSTORE_PASSWORD | Your keystore password |
ANDROID_KEY_ALIAS | Your key alias |
ANDROID_KEY_PASSWORD | Your key password |
These are encrypted at rest, never printed in logs, and never leave GitHub. EXPO_TOKEN authenticates the CLI as you, the four ANDROID_* secrets are what actually sign the bundle (via the credentials.json the workflow builds from them). The secrets that used to live on your laptop (or worse, a sticky note) now live in one secure place.
Step 4: The workflow file
Create .github/workflows/android-production.yml. This is the heart of the setup — read the notes after the block if any step feels magic.
name: Android Production Build
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: unstuck-app
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: unstuck-app/package-lock.json
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Install dependencies
run: npm ci
- name: Check versionCode is bumped
run: |
CURRENT=$(node -e "console.log(require('./app.json').expo.android.versionCode)")
PREV=$(git show HEAD~1:unstuck-app/app.json | node -e "const d=require('fs').readFileSync('/dev/stdin','utf8'); console.log(JSON.parse(d).expo.android.versionCode)")
if [ "$CURRENT" -le "$PREV" ]; then
echo "ERROR: versionCode ($CURRENT) must be greater than last commit ($PREV). Bump it in app.json before running this workflow."
exit 1
fi
echo "versionCode OK: $PREV → $CURRENT"
- name: Install EAS CLI
run: npm install -g eas-cli
- name: Restore keystore and credentials
run: |
mkdir -p credentials/android
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > credentials/android/keystore.jks
cat > credentials.json << EOF
{
"android": {
"keystore": {
"keystorePath": "credentials/android/keystore.jks",
"keystorePassword": "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}",
"keyAlias": "${{ secrets.ANDROID_KEY_ALIAS }}",
"keyPassword": "${{ secrets.ANDROID_KEY_PASSWORD }}"
}
}
}
EOF
- name: Build Android AAB
run: eas build --platform android --profile production --local --non-interactive
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
- name: Locate AAB
run: ls -lh *.aab
- name: Upload AAB artifact
id: upload
uses: actions/upload-artifact@v4
with:
name: nudge-android-${{ github.run_number }}
path: unstuck-app/*.aab
retention-days: 14
- name: Publish artifact download link
run: |
{
echo "### 📦 Android AAB ready"
echo ""
echo "**Download:** ${{ steps.upload.outputs.artifact-url }}"
echo ""
echo "_Requires GitHub sign-in. Expires in 14 days._"
} >> "$GITHUB_STEP_SUMMARY"A few lines are doing more work than they look:
workflow_dispatch— this is what makes it a button. The build only runs when you manually trigger it, so you're never surprised by a release you didn't ask for.fetch-depth: 2 + the versionCode check— Play Store rejects any upload whoseversionCodeisn't higher than the last one, and forgetting to bump it is a classic way to waste a 15-minute build. This step compares theversionCodeinapp.jsonagainst the previous commit's and fails fast if you forgot. (fetch-depth: 2is what makes the previous commit available to diff against — the default shallow checkout only fetches one.)--local— the whole reason we're here. It builds on the GitHub runner itself instead of Expo's cloud builders, so the job doesn't consume your EAS build quota. Debug all month, then ship this way regardless of how many cloud builds you've got left.--non-interactive— non-negotiable for CI. Without it, EAS will pause to ask a question, and since nobody's there to answer, the job hangs until it times out.- The
Restore keystore and credentialsstep — this is where the base64 string becomes a real.jksfile again, and wherecredentials.jsongets assembled from your secrets. Because you setcredentialsSource: "local"in Step 1, EAS reads this file to sign the bundle (EXPO_TOKENonly authenticates the CLI). Nothing sensitive is committed — it all lives in the runner's temporary filesystem and vanishes when the job ends. - The download-link step — writes a clickable artifact link into the run summary (
$GITHUB_STEP_SUMMARY), so you don't have to scroll hunting for the.aab. Handy if you hand builds off to someone else to submit. retention-days: 14— the finished.aabstays downloadable for two weeks, then GitHub cleans it up.
Note the working-directory: unstuck-app — our app lives in a subfolder of a monorepo. If your Expo project is at the repo root, drop that block and adjust the unstuck-app/ paths in the cache, artifact, and versionCode steps.
Step 5: Push the button

1. Open your repo on GitHub
2. Click the Actions tab
3. Pick Android Production Build from the left sidebar
4. Hit Run workflow → Run workflow
The build runs about 10–15 minutes. When it's done, the run summary shows the download link from that last step, and your .aab is also under Artifacts at the bottom of the page.
That's the whole payoff. From "I want to ship" to "signed bundle in hand" without opening an editor.
Step 6: Send it to the Play Store
Download the artifact, unzip it to get the .aab, and submit with EAS:
cd your-app-directory
eas submit --platform android --profile production --path "/path/to/build.aab"When something goes wrong
base64: invalid input
Your ANDROID_KEYSTORE_BASE64 secret has line breaks in it. Re-encode with the single-line PowerShell method from Step 2 and update the secret. This is the single most common failure — check it first.
eas build hangs waiting for input
You're missing --non-interactive on the build command. Add it.
versionCode … must be greater than last commit
The guard step did its job: you didn't bump versionCode in app.json. Increment it, commit, and re-run. (If you did bump it and still see this, make sure fetch-depth: 2 is on the checkout step — without it there's no previous commit to compare against.)
Build can't find the keystore, or signs with the wrong key
Three things to check: that credentialsSource: "local" is set on your production profile in eas.json (without it, EAS ignores credentials.json and uses remote credentials); that all four ANDROID_* secrets are set and spelled exactly as in Step 3; and that the "Restore keystore and credentials" step runs before the build step so credentials.json exists when EAS looks for it.
Wait — doesn't GitHub have its own limit?
Fair question, and it'd be dishonest to dodge it: yes, GitHub Actions has a monthly minute allowance too. Our repo is private — as most commercial app repos are — so this genuinely applies to us. The reason it still comes out ahead is that the two quotas are wildly different sizes for this use case.
On private repos, GitHub's included minutes are:
Plan | Included Minutes/Month |
|---|---|
Free | 2000 |
Pro | 3000 |
Team | 3000 |
Enterprise | 50,000 |
You're using 5% of the free tier. Plenty of room.
(If your repo is public , none of this matters — GitHub gives public repos unlimited Actions minutes.)
Two details decide how fast you burn through the private-repo allowance:
- Runner OS multiplier. GitHub counts minutes by runner type: Linux ×1, Windows ×2, macOS ×10. This workflow runs on
ubuntu-latest(Linux), so a 15-minute build costs 15 minutes — the cheapest possible rate. (This is also a good reason not to reach for a macOS runner unless you genuinely need one — the same build there would cost 150 minutes.) - How often you build. At ~15 Linux minutes per production build, the Free tier's 2,000 minutes is roughly 130 builds a month.
Where to take it from here
This setup is deliberately manual — a button you press when you decide it's time. Once you trust it, there are a few natural upgrades:
- Fully automate the Play Store push. Add the r0adkll/upload-google-play action after the build step and hand it a Google service account. Then the workflow builds and submits in one run.
- Build on every merge. Swap workflow_dispatch for push: branches: [main] and every merge to main produces a fresh bundle. (Personally, we like the button — but this is great for a staging track.)
- Do the same for iOS. The identical pattern works with --platform ios; you swap the Android keystore secrets for Apple signing credentials (distribution certificate + provisioning profile) in an iOS block of credentials.json.
I set this up the day I ran out of cloud builds mid-release. Now the build quota is a non-issue — I build as much as I need while debugging, and ship from GitHub whenever I'm ready. Running low on EAS credits stopped being a thing I think about.
