Out of EAS Build Credits? Build Your App from GitHub Instead | Crescentic
July 13, 2026Reading time: 10 min
Out of EAS Build Credits? Build Your App from GitHub Instead
I ran out of EAS build credits right when I needed to ship. Here's the GitHub Actions pipeline I set up to build a signed Android app on a free Linux runner without touching Expo's cloud build quota.
Uttam - Principal Engineer
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 account settings
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:
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.
base64 -w 0 keystore.jks | pbcopy # macOS
base64 -w 0 keystore.jks # Linux — copy the output manually
The 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.
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 whose versionCode isn't higher than the last one, and forgetting to bump it is a classic way to waste a 15-minute build. This step compares the versionCode in app.json against the previous commit's and fails fast if you forgot. (fetch-depth: 2 is 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 credentials step — this is where the base64 string becomes a real .jks file ag ain, and where credentials.json gets assembled from your secrets. Because you set credentialsSource: "local" in Step 1, EAS reads this file to sign the bundle (EXPO_TOKEN only 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 .aab stays 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:
bash
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 1. credentialsSource: "local" is set on your production profile in eas.json. Without it, EAS ignores credentials.json and falls back to remote credentials (a different key). 2. All four ANDROID_* secrets are set, spelled exactly as in Step 3. A typo'd or missing secret means the keystore can't be reconstructed. 3. The "Restore keystore and credentials" step runs before the build step. Otherwise credentials.json doesn't exist yet 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
Build minutes / build21
Production builds / month5
Runner OS
Minutes used
105
Free tier included
2,000
Headroom left
1,895
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.