Quickstart
From zero to your first OTA update in about ten minutes. No Expo account, no EAS, no cloud lock-in — just Otapush and your app.
What you need before you start
- An Otapush account with an app created in the portal. A new app comes with three channels (
dev,staging,prod) and its own code-signing keypair — both are created for you. - A project to wire up: an Expo project (managed or prebuild), or a bare React Native project with
expoandexpo-updatesinstalled — see Bare React Native for that path. - Node 18+ with npm for the CLI (Bun works too).
- A release build on a real device or emulator. OTA updates apply only to release builds — development clients load JavaScript from Metro and never talk to the update server.
That is the whole checklist. You do not need an Expo/EAS account, a store submission, or any server-side SDK.
1. Create an account and an app
Open the web portal, register, and click New app. Pick a name and a slug — the slug becomes part of your updates URL:
https://your-server.example.com/api/updates/<slug>/manifest
The app is created with the three channels and a unique code-signing keypair (the private key stays on the server; the public certificate you will fetch in the next step). Keep the init dialog open — it shows everything you need for the steps below.
2. Install the CLI and log in
npm install -g otapush
# or: bun add -g otapush
otapush login --server https://your-server.example.com
otapush whoami
login is interactive — it asks for the portal email and password and stores a token in ~/.otapushrc.json. Publishing is authorized by a separate, app-scoped API key, not by this login; that split is covered in the CLI reference.
3. Link the project with otapush init
From the project root:
otapush init
init asks which app to bind (it lists the apps on your account and can create a new one), then writes three things:
certs/certificate.pem— the app's public code-signing certificate, fetched from the server. Commit it; the client verifies manifest signatures against it.app.json— theexpo.updatesblock pointing at your server, plusruntimeVersiononly ifapp.jsondoes not already have one (an existing value is never overwritten).otapush.config.json— the project↔app binding: server URL, app id, slug, runtime version, project type.
What init does not do: install npm packages, set checkAutomatically or fallbackToCacheTimeout (the expo-updates defaults apply), or touch native files in a managed project.
The resulting app.json section looks like this:
{
"expo": {
"updates": {
"url": "http://localhost:3000/api/updates/my-app/manifest",
"enabled": true,
"codeSigningCertificate": "./certs/certificate.pem",
"codeSigningMetadata": { "keyid": "main", "alg": "rsa-v1_5-sha256" }
},
"runtimeVersion": "1.0.0"
}
}
Bare React Native project (no
app.json)?initdetects that and patchesAndroidManifest.xmlandExpo.plistinstead — follow Bare React Native and come back at step 4.
A device id is required. The server rejects update requests without a stable
deviceId(400 deviceId is required). Register one at startup withUpdates.setExtraParamAsync("deviceid", id)— key lowercase; the snippet is in Expo setup, and the working demo isexamples/expo-demo/App.tsx.
The URL, the certificate and the runtime version are compiled into the binary at build time. That is why step 4 is a real build — and why, after it, JavaScript changes ship without one.
4. Build a release binary once
OTA applies only to release builds, so build one without EAS:
npx expo prebuild --clean
cd android && ./gradlew assembleRelease # Android
# iOS: build the Release configuration from Xcode or xcodebuild
Install it on a device or emulator and launch it once — an embedded bundle with no server updates yet. Full details, including iOS signing, are in Expo setup. This is the last build a JavaScript-only change ever needs.
5. Publish your first update
Make a visible JavaScript change, then:
otapush publish --channel prod --platform android --message "First OTA update"
Why prod: otapush init writes the plain manifest URL without a channel parameter, and the server serves the prod channel when none is requested — prod is the channel your build actually checks. (dev and staging are for builds whose URL carries ?channel=<name>; see the channels question below.)
The CLI exports the JS bundle and assets, uploads them, signs the manifest, and points the channel at the new update. Restart the app twice: the first launch downloads the update, the second runs it. The Overview tab should now show a check, a download and an install event.
6. Promote instead of republishing
Once you use a testing channel, move to production the exact artifact you tested — not a rebuild:
otapush publish --channel staging --platform ios --message "Ready for prod"
otapush promote --from staging --to prod
promote copies the channel's current update into the target channel for that update's platform. Channels hold one update per platform, so repeat for the other platform, or use the portal's Updates tab, which does it per platform with a button.
7. Roll back when things go wrong
otapush rollback --channel prod
The channel steps back to the previous update for that platform, and devices recover on their next check — no store release involved. Rolling back the channel's first update is also handled: devices are told to revert to the bundle compiled into the binary (rollBackToEmbedded — see Protocol). The portal has one-click rollback per platform.
Questions and answers
What is a runtime version, and when do I change it?
runtimeVersion is the compatibility gate between a binary and a JavaScript update: the server serves an update only to clients whose runtime version matches exactly, and never otherwise. Change it whenever the native side changes — a new native dependency, an Expo SDK upgrade, any native code edit — and rebuild the app; see Store policies for why this boundary exists.
One trap to avoid: otapush init does not overwrite a runtimeVersion already present in app.json, while otapush publish takes the value from otapush.config.json (ultimately from the server's app record). If you hand-edit the version in app.json, pass --runtime-version on publish (or update the config) — otherwise updates go out under a version no binary requests.
Why do I have to rebuild the app after running init?
Because init changes native configuration, and native configuration ships only with a binary: the updates URL, the signing certificate and the runtime version are read by the native expo-updates module, not by your JavaScript. What ships over the air afterwards is JavaScript and assets only — that is the whole deal of the protocol.
The device doesn't see my update — what now?
Work down the list; each item is a real failure mode:
- Runtime version match. The update's
runtimeVersionmust equal the binary's exactly — no ranges, no nearest match. - The channel. A build with the plain manifest URL checks
prod; publishing todevorstagingis invisible to it. Point the URL at?channel=<name>for non-prod builds. - A release build. Dev clients and debug builds load from Metro and never check the server.
- Two launches. The first downloads, the second applies — with
fallbackToCacheTimeout: 0there is no waiting on the first one. - The Overview tab. No
checkevents at all: the device never reached the server (URL, network, ATS blocking plain HTTP on iOS). Checks but noinstall: updates download but fail to launch — signature problems are the usual cause. - The device id. A missing or non-lowercase
deviceidextra param ends in400 deviceId is required; see Expo setup. - A stale certificate. If you re-ran
initand got a fresh certificate after the binary was built, rebuild withnpx expo prebuild --clean— the certificate is baked in.
How is a channel different from a branch?
There are no branches in Otapush — channels only, and they are simpler than EAS branches. A channel is a named pointer that holds one current update per platform (dev, staging, prod exist by default; more can be created in the portal). Publishing moves the pointer, promote copies it to another channel, rollback steps it back. Every update ever published stays listed and can be promoted again later — but there is no history graph, no partial rollouts by percentage, and a device selects its channel only through the URL it was built with.
Can I follow this quickstart with a bare React Native app?
Yes. otapush init detects a bare project (no app.json, react-native in dependencies) and patches AndroidManifest.xml and Expo.plist instead of app.json; publishing runs npx react-native bundle instead of expo export. Everything else — channels, deviceId, publish/promote/rollback — is identical; Bare React Native has the details.
Where next
- CLI reference — every command, flag and exit code, plus the CI setup.
- Protocol — how the server talks to
expo-updatesclients, request by request. - Store policies — what Apple and Google allow over the air, and the pre-push checklist.