Everything you need to ship your first OTA update — CLI commands, SDK configuration, and what the Cloud dashboard actually does today (no fabricated features).
OpenOTA lets you ship JavaScript changes to a React Native app instantly — no app store review, no waiting days for a rollout. You build a bundle, push it to your own server (or OpenOTA Cloud), and every installed app checks in, downloads, verifies, and applies the update on its own. If something goes wrong, rollback is instant and happens entirely on-device.
It's the same idea as CodePush or Expo Updates, with two differences that shape everything else in these docs: you own the server (self-host it, or use the hosted Cloud — same code, your choice), and the device never trusts the server blindly — the native runtime re-verifies every bundle's checksum itself before running it.
Your RN app (SDK) ──check/download──▶ OpenOTA Server
▲ │
│ verify + apply ├──▶ Postgres (metadata)
│ └──▶ Storage (bundle .zip)
openota CLI ─────────release────────────────▶openota release bundles your JS (via Metro), computes a SHA-256 checksum, and uploads it to your server.OTA.check() asks the server "what's active for my platform + environment?" and compares it to the device's current version.One versioned JS bundle for one platform — built by openota build, checksummed, and uploaded. Immutable once uploaded.
Production / Staging / Development. Each has its own independent "active" release per platform — releasing to Staging never touches Production.
A compatibility fence, not a feature version. A device only accepts an OTA release whose runtimeVersion exactly matches its own native binary.
Moves an environment's active pointer back to a previously-uploaded release. Instant, on-device, no re-download needed — nothing is ever deleted.
If you're using OpenOTA Cloud, all of this is already running — skip straight to Quickstart. If you're self-hosting, here's exactly what's required vs. optional.
Self-hosted (docker compose up) or OpenOTA Cloud — identical API either way, your app never knows the difference.
Unset falls back to an embedded file-based DB (PGlite) — fine for one instance/testing, not for anything you'd call production.
local disk (needs a persistent volume) or supabase (Supabase Storage). This is what actually holds your JS bundle bytes — kept deliberately separate from Postgres.
Unset means verification/reset links are logged to the server console instead of emailed — fully functional without it.
No Redis, no message queue, no separate auth provider — deliberately minimal.
npm install -D @openota/cli
npx openota init --server-url https://YOUR-SERVER/api/v1 --runtime-version 1.0.0
npx openota doctor
npx openota release --version 1.0.1 --platform androidopenota init detects your package manager (npm, yarn, or pnpm) and automatically installs @openota/sdk, @openota/native-android, and every native peer dependency the SDK needs — nothing to install by hand. Pass --skip-install to opt out. If a dependency ever goes missing later, npx openota doctor --fix repairs it the same way.
Full native wiring (Android/iOS) and SDK integration walkthrough lives in docs/GETTING_STARTED.md.
You run the server. One optional shared secret (OPENOTA_API_KEY), one flat storage namespace, CLI only — no dashboard concept needed.
Per-user accounts, per-project API keys, isolated storage per project, full dashboard — projects, releases, rollback, devices, analytics.
Both modes run from the exact same server binary and coexist — turning on Cloud never breaks the self-hosted flat routes. Full reference: docs/CLOUD.md.
| Command | What it does |
|---|---|
| openota init | Writes openota.config.json — server URL, runtime version, platforms. |
| openota login --api-key <key> | Stores a Cloud API key in ~/.openota/credentials.json (0600), auto-resolves projectId. |
| openota doctor | Checks Node version, RN detection, native dirs, Metro, config, auth, server reachability, project access. |
| openota build | Bundles JS via Metro and computes the manifest (version, runtimeVersion, SHA-256). |
| openota upload | Uploads an already-built package without changing the active version. |
| openota release --version <v> --platform <p> | build + upload + activate in one step — the command you run in CI. |
| openota rollback --platform <p> --version <v> | Points the active-version pointer at an already-uploaded release. |
| openota logout | Removes the stored API key for the configured server URL. |
| Option | Type | Required | Notes |
|---|---|---|---|
| serverUrl | string | required | Your OpenOTA server's API base, including /api/v1. |
| channel | string | optional | Defaults to "production". Selects which environment's active release this device receives — each environment tracks its own independently. |
| autoRestart | boolean | optional | Reload the JS bundle automatically after install/rollback. Default true. |
| requestTimeout | number | optional | Milliseconds before a check/download request aborts. Default 15000. |
| headers | Record<string,string> | optional | Extra headers sent on every request. |
| projectId | string | optional | OpenOTA Cloud only — targets the project-scoped routes and enables device tracking + install-result reporting. Omit for self-hosted. |
Setting projectId also makes the SDK send an anonymous, auto-generated device ID on every check/download, and report install/rollback outcomes automatically — that's what populates the dashboard's Devices and Analytics pages. Nothing extra to wire up.
@openota/sdk is a thin JS layer over a few native modules. openota init installs these automatically (see Quickstart above) — but every one of them still needs a real native rebuild before the SDK will work, not just a JS reload.
| Package | What it's for |
|---|---|
| react-native-mmkv | Fast on-device key/value cache for the current version, bundle path, manifest, and anonymous device ID. |
| react-native-nitro-modules | Required by the Nitro-based versions of mmkv and quick-crypto. A peer dependency of both, but never auto-installed — see below. |
| react-native-fs | Filesystem access for downloading and staging the update bundle. |
| react-native-zip-archive | Extracts the downloaded .zip bundle before it's verified and installed. |
| react-native-quick-crypto | Computes the SHA-256 checksum used to verify every bundle before it runs. |
| react-native-quick-base64 | A peer dependency of quick-crypto itself — easy to miss since npm hoists it without you ever installing it directly. |
# Already installed for you by `openota init` — only needed if you're adding these manually
npm install react-native-mmkv react-native-nitro-modules \
react-native-fs react-native-zip-archive \
react-native-quick-crypto react-native-quick-base64
# iOS — required after installing or upgrading any of the above
cd ios && bundle exec pod install && cd ..
# Android — autolinking picks these up automatically, but only on a full rebuild
cd android && ./gradlew clean && cd ..
npx react-native run-ios # or run-androidForgetting the native rebuild is the single most common integration mistake — it surfaces as a vague JS error like Cannot read property 'otaStorage' of undefined the moment OTA.configure() or OTA.sync() runs, since the native module the SDK depends on was never linked into the app binary. npx openota doctor checks for this.
Separate from OTA bundles entirely — a per-(project, platform) JSON value your app can fetch at runtime and react to however you like. Editable from the dashboard's Apps page, no new release required to change it.
GET /projects/:projectId/apps/:platform/config # public, no auth — same as check/download
PUT /projects/:projectId/apps/:platform # session-authed, dashboard/API only
body: { "remoteConfig": { "anyKey": "anyValue" } }OpenOTA never reads or acts on the value itself — what it means is entirely up to your app. Two things worth knowing: the endpoint sends Cache-Control: no-store, and this is a plain fetch() that does not inherit OTA.configure()'s requestTimeout — add your own AbortController timeout, or a slow/cold server can hang the request far longer than expected.
By default a device only learns about a release the next time your app calls OTA.sync() — OpenOTA never polls on its own. To have the server nudge an already-open app instantly instead of waiting for the next launch or resume, open a live connection once:
OTA.connectLive(); // e.g. right after OTA.configure()
// ...
OTA.disconnectLive(); // e.g. on unmount
// or react to it yourself instead of the default silent OTA.sync():
OTA.connectLive(() => {
console.log('a release just went out — re-checking now');
OTA.sync();
});Pure JS — React Native's built-in WebSocket, no native setup, no extra dependency. The server pushes a content-free "check now" nudge whenever a release, rollback, or rollout-percentage change happens on that device's channel; the real check endpoint (with its staged-rollout gate) stays the single source of truth for what a device is actually eligible for — nothing is pushed except the nudge itself. Reconnects automatically with backoff. Only reaches the app while it's open or backgrounded-but-alive — a fully closed app isn't woken up (that would need push notifications, which OpenOTA doesn't do yet).
Email/password or Google sign-in, email verification, forgot/reset password. Google links onto an existing account by matching email rather than duplicating it.
Create, rename, delete. Each isolates its own releases, keys, and devices.
Full key shown once on creation, stored server-side only as a hash.
Production/Staging/Development per project, each with its own release, visual rollback, and deployment history. Rolling back only changes what an environment offers — a device already ahead of the rollback target won't downgrade (the check endpoint never silently downgrades a device); it applies to devices still behind it, same as any other update.
Package name, bundle identifier, runtime version, min supported version, and remote config — per (project, platform).
Real per-device last-seen registry, populated by the SDK automatically.
Downloads, success rate, failures, rollbacks — all real, none fabricated.
| Variable | Required | Notes |
|---|---|---|
| DATABASE_URL | no | Unset = embedded PGlite. postgres://... for managed Postgres (Supabase in Cloud). |
| SESSION_SECRET | yes in prod | Signs dashboard session cookies. |
| CORS_ALLOWED_ORIGINS | yes for cross-site dashboard | Comma-separated allowlist for the dashboard's credentialed requests. |
| STORAGE_PROVIDER | no | "local" or "supabase". Default local. |
| RESEND_API_KEY | no | Sends verification/reset emails. Unset = link is logged to the server console instead. |
| DASHBOARD_URL | no | Base URL used to build verification/reset and Google sign-in redirect links. |
| OPENOTA_API_KEY | no | Legacy single-tenant shared secret for self-hosted flat routes. |
| GOOGLE_CLIENT_ID / _SECRET / _REDIRECT_URI | no, all three or none | Enables "Continue with Google" on the dashboard login page. |
Full list including storage provider options: docs/CLOUD.md §5.
OpenOTA_Example in the monorepo is a real, working OTA client — not a mock. Every screen talks to the live OpenOTA Cloud API through the real, published @openota/sdk and @openota/native-android packages: check for update, download, verify, install, and rollback, all real. It deliberately does not reimplement release/channel management — that stays on this dashboard, exactly the separation of concerns your own app should follow.
src/context/OtaContext.tsx — the entire integration surface: one OTA.configure() call, check/sync/rollback wired to React state.android/app/.../MainApplication.kt — the required OpenOTAReactHost.create() native wiring, with the runtime-version constant.openota.config.json — a real serverUrl/projectId/runtimeVersion pointed at a live project, not a placeholder.cd OpenOTA_Example
npm install
npm run android
npx @openota/cli release --version 1.0.1Reopen the app and pull-to-refresh — the update comes from the real server response, not a fixture.
This is the actual OTA.configure() call from src/context/OtaContext.tsx — copied straight out of the reference app, not rewritten for docs.
import { OTA } from "@openota/sdk";
import config from "../../openota.config.json";
// One-time, module-level configure() — real values from openota.config.json, pointed at the live
// OpenOTA Cloud server. Everything this app shows/does from here on is driven by what that server
// returns; release/channel management itself happens on the OpenOTA Dashboard, not in this app.
OTA.configure({
serverUrl: config.serverUrl,
projectId: config.projectId,
channel: "production",
autoRestart: false, // let the UI show a clear "Update ready — restart" state first
});
// Live updates: the server nudges this connection the instant a new release/rollback/rollout
// change happens on this device's channel, instead of waiting for a manual check.
OTA.connectLive(() => setUpdateAvailable(true));Real errors this project has actually surfaced, not a generic FAQ — each one either explains intentional behavior or names a bug that's already fixed in the current release.
The uploaded zip's JS bundle doesn't match the claimed sha256/path — the server independently re-verifies both rather than trusting the CLI's claim. If you're calling the upload API directly instead of using the CLI, the bundle file must be at bundle/<bundleName> inside the zip, matching what the SDK's extractor expects on-device.
The native runtime independently re-verifies the downloaded package — it re-parses manifest.json from the extracted files on disk, not the JS object the check() call returned. A package missing manifest.json (a hand-built or corrupted zip) fails here even if the server accepted the upload, by design: the JS layer is never treated as a trust boundary.
The release's runtimeVersion doesn't match the value passed to OpenOTAReactHost.create() in MainApplication.kt. This is intentional — an OTA bundle built against a different native binary generation should never activate. Fix the mismatch, don't work around it: keep openota.config.json's runtimeVersion and the native constant in sync deliberately.
Expected, not a bug. The OTA-installed bundle lives in the app's private storage (Android: /data/data/<package>/files/OpenOTA/...) — both a full reinstall and Settings → Clear Data wipe that storage the same way Android wipes any app's private data. Check for update once more and it re-downloads cleanly.
Correct behavior on a device that has only ever had one OTA generation installed (or none) — there's genuinely nothing to roll back to yet. The native runtime only ever keeps one previous generation; install a second real update first if you want to test rollback.
If this happens on a self-hosted server with a genuinely malformed (non-zip) upload, you're hitting a real fixed bug from an earlier OpenOTA version — update to the latest server. A correctly-updated server always translates a corrupted upload into a clean 400 UPLOAD_FAILED, never a bare 500.
There is no package literally named "openota" on npm. The published package is @openota/cli — run npx @openota/cli <command>, or install it as a dev dependency and use your package manager's bin resolution (npm run / pnpm exec).
Two ways to run OpenOTA: self-hosted with Docker (your own infra, zero external accounts required), or Cloud mode across Render + Vercel (what powers api.openota.xyz and this dashboard today). Both run the exact same server code — this is a deployment choice, not a different product.
services:
server:
build:
context: .
dockerfile: apps/server/Dockerfile
ports:
- "${PORT:-3900}:${PORT:-3900}"
env_file: [.env]
volumes:
- openota_storage:/data/storage
- openota_pgdata:/repo/apps/server/data/pgdata
volumes:
openota_storage:
openota_pgdata:docker compose up -d — no .env file needed to start: local storage and an embedded PGlite database are the defaults, so this works with zero external accounts.
DATABASE_URL to a real Postgres connection string (Supabase works) so accounts/projects survive restarts, and CORS_ALLOWED_ORIGINS to your dashboard's origin.NEXT_PUBLIC_OPENOTA_SERVER_URL to your Render URL, including /api/v1.RESEND_API_KEY or SMTP_HOST/SMTP_USER/SMTP_PASS for real verification/reset emails (unset = link logged to the server console, fully functional with zero email infra); ADMIN_EMAILS to grant admin access to specific accounts.Full environment variable reference, architecture, and API docs: docs/CLOUD.md in the repo.