feb is a script that builds your Expo app locally with eas build --local — then deploys it to a device, uploads it, or submits it to the stores — with every requirement (EAS CLI, CocoaPods, Fastlane, JDK, ...) checked and installed for you. iOS and Android. No build queue, no EAS build credits, no cloud minutes.
EAS cloud builds are convenient but metered: free-tier builds are limited and queued, and paid plans charge per build. The EAS CLI can run the exact same build pipeline on your own machine with --local — same profiles, same credentials management, same output artifacts — for free and usually faster.
This script wraps that into a single command:
feb ios staging
feb android production
feb all preview- Installs build dependencies if missing — EAS CLI, CocoaPods, Fastlane, ios-deploy (via Homebrew on macOS), and checks Xcode / Android SDK / Java.
- Installs project dependencies — auto-detects pnpm / yarn / npm / bun from the lockfile, walks up parent directories so monorepos work.
- Authenticates with Expo — uses
EXPO_TOKENif set, otherwiseeas login. - Builds locally with
eas build --localfor the platform(s) and profile you pass. Artifact names and extensions (.ipa/.apk/.aab) are derived from youreas.jsonprofile. Output lands inbuild-output/. - Post-build actions (interactive prompts, skipped with
--non-interactiveor in CI):- Install on a connected iPhone (USB or WiFi via ios-deploy)
- Install on a connected Android device or emulator (adb)
- Upload artifacts to any WebDAV server (Fastmail Files, Nextcloud, ownCloud, ...) — a free way to distribute internal builds to your team
- Submit to TestFlight / Google Play via
eas submit(store-distribution profiles only)
Prefer submitting manually? The artifacts in build-output/ are standard store-ready files — upload the .ipa to App Store Connect with Apple's Transporter app, and the .aab through the Google Play Console.
- An Expo project with an
eas.json(seeeas.example.json) - A free Expo account —
eas build --localneeds authentication but does not consume build credits
The script checks every tool it needs and installs the missing ones where it safely can:
| Tool | Needed for | Auto-installed? |
|---|---|---|
| Node.js 22.x LTS | everything — EAS CLI and the build itself | No — install it yourself |
| EAS CLI | eas build --local / eas submit |
Yes (npm install -g eas-cli) |
| Homebrew | installing the macOS tools below | Yes (macOS only) |
| Xcode | iOS builds | No — App Store |
| CocoaPods | iOS builds | Yes (brew) |
| Fastlane | iOS builds (archive & signing) | Yes (brew) |
| ios-deploy | installing on a connected iPhone (interactive only) | Yes (brew; skipped in CI) |
Android SDK (ANDROID_HOME) |
Android builds | No — auto-detected in common install locations |
| JDK 17 | Android builds | Yes on macOS (brew); no on Linux |
| adb | installing on a connected Android device (interactive only) | Comes with the Android SDK |
Your project's own dependencies are installed with whatever your lockfile says — npm, yarn, pnpm, or bun all work.
Pinning versions with mise: if your project has a .mise.toml, mise.toml, or .tool-versions, the script runs mise install first and uses that toolchain — so Node and JDK come from your pins instead of whatever is on the machine (mise itself is auto-installed if missing):
# .mise.toml in your Expo project
[tools]
node = "22"
java = "temurin-17"Install with Homebrew — the command is feb — and run it from your Expo project root:
brew install ahmadatallah/tap/feb
feb ios stagingOr drop build.sh into your Expo project root (or a scripts/ folder inside it):
curl -o build.sh https://raw.githubusercontent.com/ahmadatallah/feb/main/build.sh
chmod +x build.shOptionally add npm scripts:
{
"scripts": {
"build:local": "./build.sh",
"build:local:staging:ios": "./build.sh ios staging",
"build:local:staging:android": "./build.sh android staging",
"build:local:production:all": "./build.sh all production"
}
}feb <platform> <profile> [--non-interactive] # brew install
./build.sh <platform> <profile> [--non-interactive] # vendored script
platform: ios | android | all
profile: any build profile defined in your eas.json
| Variable | Purpose |
|---|---|
APP_NAME |
Artifact filename prefix. Defaults to the name in app.json (or package.json). |
EXPO_TOKEN |
Expo access token for non-interactive auth. Otherwise eas login prompts. |
PRE_BUILD_COMMAND |
Command to run after install, before build — e.g. pnpm --filter @myorg/lib build in a monorepo. |
WEBDAV_BASE_URL |
WebDAV endpoint for artifact uploads, e.g. https://webdav.fastmail.com/files. |
WEBDAV_USERNAME |
WebDAV username. |
WEBDAV_PASSWORD |
WebDAV password / app-specific password. |
Anything not set as an env var is prompted for interactively. In --non-interactive mode (or when CI is set), all post-build prompts are skipped — the script just builds.
PRE_BUILD_COMMAND="pnpm --filter @myorg/shared build" ./build.sh all stagingThe script finds your workspace root by walking up to the lockfile, runs the install there, then runs your pre-build hook before building the app.
This repo doubles as a composite GitHub Action — build in CI without EAS build credits:
jobs:
build:
runs-on: ubuntu-latest # macos-latest for iOS
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/setup-java@v4 # Android only
with:
distribution: temurin
java-version: 17
- uses: ahmadatallah/feb@v1.1.2
id: build
with:
platform: android
profile: production
expo-token: ${{ secrets.EXPO_TOKEN }}
# working-directory: apps/mobile # monorepos
# pre-build-command: pnpm --filter @myorg/lib build
- uses: actions/upload-artifact@v4
with:
name: android-build
path: ${{ steps.build.outputs.android-artifact }}Inputs: platform, profile, expo-token (required); working-directory, pre-build-command, app-name (optional).
Outputs: ios-artifact, android-artifact, output-dir.
Full workflow examples in examples/. iOS builds need runs-on: macos-latest (Xcode preinstalled on GitHub-hosted macOS runners).
Hashcards — flashcards with free spaced-repetition scheduling (FSRS). Ships its iOS and Android builds with this action.
Using free-expo-builds in your app? Open a PR to add it here.
- Credentials:
eas build --localstill uses EAS-managed credentials (signing certs, provisioning profiles, keystores) if you have them configured — or a localcredentials.json. See the local builds docs. - Simulator profiles (
ios.simulator: true) produce a.tar.gzcontaining an.app, not an.ipa— the script names artifacts accordingly and skips the device-install prompt. - Disk space: local builds compile the whole native project; expect several GB of intermediate artifacts under the EAS temp directory.
- Reproducibility: cloud builds run in a clean container; local builds run on your machine. Pin your toolchain with a
.mise.toml(see Dependencies) and your Node/pnpm versions ineas.json(node,pnpmfields) to keep them close.
mise install # pinned dev toolchain (node, shellcheck) from .mise.toml
bash tests/run-tests.shRuns the test suite against fixture projects with all external commands (eas, brew, package managers) stubbed — no network, no real builds. CI runs shellcheck + the suite on every push/PR.