Quick start
In the folder of an Expo app, link the project once. It signs you in to localxpo Cloud, picks the organization, creates the project in the dashboard and sets up your store credentials.
npx localxpo linknpx localxpo build -p android --profile preview # build here, logs stream to the dashboardnpx localxpo build -p ios --profile production --submit # build, then upload to TestFlight
link writes a small localxpo.json in the project with the dashboard URL and project id; every other command reads it. Profiles come from your existing eas.json, so nothing needs to change in the app.
Requirements
- Node.js 20 or newer, and the package manager of the project (npm, yarn, pnpm or bun).
- fastlane:
brew install fastlaneorgem install fastlane. AGemfilewith fastlane is used throughbundle exec. - Android: a JDK and the Android SDK (
ANDROID_HOMEset). Any macOS, Linux or Windows machine works. - iOS: macOS with Xcode and CocoaPods.
Check a machine before the first build:
npx localxpo doctor # Node, fastlane, JDK, Android SDK, Xcode, CocoaPods…
How it works
The CLI runs the whole build on your machine with fastlane, and reports to the dashboard over HTTPS: log lines in batches, steps, metrics and the result. The dashboard never runs builds and never receives your source code or binaries.
| Step | Android | iOS (macOS) |
|---|---|---|
| Read app config | eas.json profile, then npx expo config with its env | Same |
| Install dependencies | Frozen lockfile | Same |
| Native fingerprint | @expo/fingerprint, compared with earlier builds | Same |
| Generate native project | expo prebuild when android/ is generated | Same with ios/ |
| Build | fastlane gradle, signed with the upload key | fastlane build_app with the App Store Connect key |
| Upload | fastlane upload_to_play_store | fastlane upload_to_testflight |
The Fastfile is written to a private temporary folder for each build, never into your project, and every value is passed as an environment variable. Key files are deleted when the build ends, including on Ctrl+C.
Commands
| Command | What it does |
|---|---|
link | Sign in, pick the organization, connect this project, set up store credentials |
build -p android|ios | Build on this machine and stream it to the dashboard |
submit | Upload one of your builds to TestFlight or Google Play |
credentials | Run the store credentials setup again and check store access |
agent | Keep this machine connected so builds can start from the dashboard |
doctor | Check the tools this machine needs |
logs -f | Follow your latest build from any terminal |
builds | Recent builds of the project |
orgs | Your organizations; org use <slug> sets the default |
sync | Upload log lines that could not be sent earlier |
Run them with npx localxpo …, or install once with npm i -g localxpo (the short alias lxp works too).
Building
npx localxpo build -p ios --profile productionnpx localxpo build -p android --profile preview --clean # re-run expo prebuild --cleannpx localxpo build -p android -q # only errors and step headers
| Option | Default | Meaning |
|---|---|---|
--profile | production | eas.json build profile |
--submit | – | Upload to the store once the build succeeds |
--clean | – | Regenerate the native project |
--skip-install | – | Skip installing dependencies |
--no-limits | – | Ignore the resource limits set on the dashboard for this build |
-o, --output | localxpo-output/ | Where the .ipa, .aab or .apk is written |
--org | from localxpo.json | Organization to report to |
When a successful build already exists with the same native fingerprint, the CLI tells you; a build that only changes JavaScript can reuse it.
Submitting
npx localxpo submit # choose from a listnpx localxpo submit --latest # newest build on this machine, no promptnpx localxpo submit --build=<id> # a specific buildnpx localxpo submit --path app.aab # any .ipa or .aab
Each upload is a submission with its own logs on the dashboard. Binaries never leave the machine, so a build is submitted from the machine that made it (or with --path). The Play track comes from submit.<profile>.android.track in eas.json (default internal).
Store credentials
link and credentials find what builds and uploads need, and only ask for what they can't find:
- From Expo: with
eas loginorEXPO_TOKEN, Android upload keystores and the App Store Connect key ids are imported. - From this machine: eas.json submit settings, fastlane files,
~/.appstoreconnect/private_keys, Downloads and Desktop. - Created for you: a Google Play service account through
gcloud, or guided downloads from Google Cloud and App Store Connect. - Checked: each key is verified with Apple or Google before it is saved.
Keys are stored encrypted (AES-256-GCM). Apple and Google keys belong to the organization and only owners can add them; Android keystores belong to the project, one per package.
Agents
Run the agent on each machine that should take builds started from the dashboard:
npx localxpo agent # serve this projectnpx localxpo agent --dir ../other-app # serve another project from the same machinenpx localxpo agent --list # what this machine serves
It checks in every few seconds and runs one job at a time with the regular CLI, so logs, limits and credentials behave as in a terminal. A Mac builds iOS and Android; Linux and Windows build Android. A build whose machine stops responding for three minutes is marked failed, and resumes if the machine comes back.
CI and tokens
Create a token on CLI & tokens in the dashboard, then skip link in CI or on a headless Mac:
LOCALXPO_URL=https://your-dashboard LOCALXPO_TOKEN=lx_… npx localxpo build -p iosLOCALXPO_NO_METRICS=1 npx localxpo build -p android # turn machine metrics off
Environment variables
Variables are set per project and per environment (development, preview, production) on the dashboard, and injected by the CLI. The environment follows the profile's environment in eas.json, otherwise the profile name. Secret values are hidden in the dashboard and removed from logs.
Metrics and limits
During each build the CLI samples CPU, memory, memory pressure, swap, the heaviest build processes, power and free disk every 5 seconds, without sudo. The build page charts them per step, and the Machines page compares machines over 30 days.
| Limit | Effect |
|---|---|
| Gradle memory | Sets the Gradle and Kotlin daemon heaps, overriding gradle.properties |
| Parallel workers | Gradle --max-workers, Xcode -jobs |
| Low CPU priority | Build commands run under nice, so the machine stays responsive |
| Stop Gradle daemons | Gives their memory back when the build ends |
Limits are set on the Machines page, per machine or as a default, with values recommended for each machine's hardware.
Logs and errors
- Every line is redacted on your machine first (keystore passwords, tokens, PEM blocks), then sent in batches every second.
- If the network drops, lines are queued and saved to
~/.localxpo/pending/; the next command (orlocalxpo sync) uploads them. - About 30 known Xcode, Gradle, CocoaPods and store failures are recognised and shown with what went wrong and how to fix it.
REST API
Send a token with every request: Authorization: Bearer lx_…. Tokens are created on CLI & tokens; the browser session also works for GET requests.
curl -H "Authorization: Bearer $LOCALXPO_TOKEN" \"https://your-dashboard/api/v1/builds?projectId=…&status=success&limit=5"
| Endpoint | What it does |
|---|---|
POST/api/v1/auth/login | Email and password → API token and organizations |
GET/api/v1/me | The signed-in user |
GET/api/v1/orgs | Your organizations |
GET/api/v1/projects?org= | Projects of an organization |
POST/api/v1/projects | Create or update a project by slug (what link does) |
GET/api/v1/projects/:id/env?environment= | Environment variables for development, preview or production |
GET/api/v1/builds?projectId=&platform=&status= | List builds, newest first (limit, offset) |
POST/api/v1/builds | Start a build record |
GET/api/v1/builds/:id | One build, with steps and status |
PATCH/api/v1/builds/:id | Finish a build: status, error, artifact, fingerprint |
PUT/api/v1/builds/:id/steps | Create or update a step |
GET/api/v1/builds/:id/logs?after= | Log lines after a sequence number (?format=text to download) |
POST/api/v1/builds/:id/logs | Append log lines, idempotent on seq |
GET/api/v1/builds/compatible?fingerprint= | An earlier successful build with the same native fingerprint |
POST/api/v1/submissions | Start a store upload for a build |
PATCH/api/v1/submissions/:id | Finish an upload |
Self-hosting
The dashboard is open source (MIT). Without configuration it uses an embedded Postgres stored in ./data; set DATABASE_URL to use your own.
pnpm installpnpm dev # http://localhost:3000pnpm build && pnpm start # production
| Variable | Purpose |
|---|---|
DATABASE_URL | Postgres connection string; unset uses the embedded database |
LOCALXPO_SECRET | Key that encrypts store credentials. Set it in production and back it up |
NEXT_PUBLIC_APP_URL | Public URL shown in CLI commands |
LOCALXPO_DISABLE_SIGNUP | Set to 1 to close registration |
Most teams start on localxpo Cloud. It runs this same open-source code, kept up to date for you, with Pro features for teams. Self-hosting makes sense when the dashboard must stay on your network.
Prefer not to run a server? Create a free localxpo Cloud account; builds still run on your machines.