localxpo for developers

Everything to build and ship Expo apps from your own machines: the CLI, store credentials, dashboard builds, CI and the REST API. localxpo is open source under MIT; the quickest way to start is localxpo Cloud, where nothing needs to be hosted.

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.

Terminal
npx localxpo link
npx localxpo build -p android --profile preview # build here, logs stream to the dashboard
npx 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 fastlane or gem install fastlane. A Gemfile with fastlane is used through bundle exec.
  • Android: a JDK and the Android SDK (ANDROID_HOME set). Any macOS, Linux or Windows machine works.
  • iOS: macOS with Xcode and CocoaPods.

Check a machine before the first build:

Terminal
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.

StepAndroidiOS (macOS)
Read app configeas.json profile, then npx expo config with its envSame
Install dependenciesFrozen lockfileSame
Native fingerprint@expo/fingerprint, compared with earlier buildsSame
Generate native projectexpo prebuild when android/ is generatedSame with ios/
Buildfastlane gradle, signed with the upload keyfastlane build_app with the App Store Connect key
Uploadfastlane upload_to_play_storefastlane 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

CommandWhat it does
linkSign in, pick the organization, connect this project, set up store credentials
build -p android|iosBuild on this machine and stream it to the dashboard
submitUpload one of your builds to TestFlight or Google Play
credentialsRun the store credentials setup again and check store access
agentKeep this machine connected so builds can start from the dashboard
doctorCheck the tools this machine needs
logs -fFollow your latest build from any terminal
buildsRecent builds of the project
orgsYour organizations; org use <slug> sets the default
syncUpload 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

Terminal
npx localxpo build -p ios --profile production
npx localxpo build -p android --profile preview --clean # re-run expo prebuild --clean
npx localxpo build -p android -q # only errors and step headers
OptionDefaultMeaning
--profileproductioneas.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, --outputlocalxpo-output/Where the .ipa, .aab or .apk is written
--orgfrom localxpo.jsonOrganization 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

Terminal
npx localxpo submit # choose from a list
npx localxpo submit --latest # newest build on this machine, no prompt
npx localxpo submit --build=<id> # a specific build
npx 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:

  1. From Expo: with eas login or EXPO_TOKEN, Android upload keystores and the App Store Connect key ids are imported.
  2. From this machine: eas.json submit settings, fastlane files, ~/.appstoreconnect/private_keys, Downloads and Desktop.
  3. Created for you: a Google Play service account through gcloud, or guided downloads from Google Cloud and App Store Connect.
  4. 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:

Terminal
npx localxpo agent # serve this project
npx localxpo agent --dir ../other-app # serve another project from the same machine
npx 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:

Terminal
LOCALXPO_URL=https://your-dashboard LOCALXPO_TOKEN=lx_… npx localxpo build -p ios
LOCALXPO_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.

LimitEffect
Gradle memorySets the Gradle and Kotlin daemon heaps, overriding gradle.properties
Parallel workersGradle --max-workers, Xcode -jobs
Low CPU priorityBuild commands run under nice, so the machine stays responsive
Stop Gradle daemonsGives 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 (or localxpo 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.

Example
curl -H "Authorization: Bearer $LOCALXPO_TOKEN" \
"https://your-dashboard/api/v1/builds?projectId=…&status=success&limit=5"
EndpointWhat it does
POST/api/v1/auth/loginEmail and password → API token and organizations
GET/api/v1/meThe signed-in user
GET/api/v1/orgsYour organizations
GET/api/v1/projects?org=Projects of an organization
POST/api/v1/projectsCreate 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/buildsStart a build record
GET/api/v1/builds/:idOne build, with steps and status
PATCH/api/v1/builds/:idFinish a build: status, error, artifact, fingerprint
PUT/api/v1/builds/:id/stepsCreate 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/logsAppend log lines, idempotent on seq
GET/api/v1/builds/compatible?fingerprint=An earlier successful build with the same native fingerprint
POST/api/v1/submissionsStart a store upload for a build
PATCH/api/v1/submissions/:idFinish 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.

Terminal
pnpm install
pnpm dev # http://localhost:3000
pnpm build && pnpm start # production
VariablePurpose
DATABASE_URLPostgres connection string; unset uses the embedded database
LOCALXPO_SECRETKey that encrypts store credentials. Set it in production and back it up
NEXT_PUBLIC_APP_URLPublic URL shown in CLI commands
LOCALXPO_DISABLE_SIGNUPSet 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.