EdgeOTA Platform Documentation
Deploy signed, zero-egress over-the-air updates for Expo React Native via global Cloudflare Edge nodes in minutes.
SaaS Cloud Quickstart
EdgeOTA is a zero-SDK drop-in replacement for EAS Update. To configure your Expo React Native app to receive updates from our edge nodes, follow the steps below using the global CLI.
Step A Install update client & CLI
Ensure the native updates package is installed in your local Expo root directory, and install the EdgeOTA CLI globally:
npm install -g @renbostudios/edge-ota
npx expo install expo-updatesStep B Authenticate your CLI session
Log in globally to save credentials on your machine. You only need to run this once:
edge-ota loginStep C Initialize project pairing
Run init inside your local Expo app root. Select your project from the server or create a new one. The CLI automatically configures app.json and generates ECDSA code-signing keys:
edge-ota initStep D Deploy signed updates & assets
Export your React Native Hermes bundle and publish signed assets directly to your channel. The CLI automatically bundles JS, discovers and uploads media assets (.png, .jpg, .ttf), and signs the payload:
edge-ota push --channel production
# Or deploy a hotfix matrix across multiple runtime versions:
edge-ota push --runtime 1.0.0,1.0.1,1.0.2 --channel productionStep E Rebuild your native app (Required)
The expo-updates native module reads its config (server URL, runtime version) from the native binary, not from app.json at runtime. After edge-ota init writes to app.json, you must rebuild the native app so those values are baked into the binary:
# regenerate native projects with fresh config
npx expo prebuild --clean
# then rebuild
npx expo run:ios
npx expo run:android
# or via EAS
eas build --profile production⚠️ Skip this step and the app will fail with:
Failed to check for update — the native module cannot reach your server because the URL was never embedded into the binary.
You must also rebuild after any change to expo.updates.url or expo.runtimeVersion in app.json.
Code-Signing Verification
To prevent middleman injections or server-breach exploits, updates must be signed cryptographically using an elliptic curve signature key (ECDSA P-256).
- Your private key stays locally inside your global keyring (
~/.config/edge-ota/keys/<projectId>.key) with0600permissions and is never transmitted over the network. - Your public key is registered in the server database automatically during
edge-ota initto authorize signature matching. - The native updates client library automatically verifies the signatures of retrieved manifests before running them on user devices.
Routing Manifest Protocol
EdgeOTA strictly complies with the official Expo Updates Protocol v1 multipart/mixed specification. When your React Native app pings the server for updates, it transmits metadata parameters in HTTP header fields:
Egress Economical Moat
Standard OTA providers charge aggressive metered bandwidth usage fees. EdgeOTA operates entirely on Cloudflare Workers and Cloudflare R2 object storage.
Because R2 guarantees zero egress bandwidth fees, we support flat-rate plans and highly generous Free tiers without crushing early-stage developers under unexpected billing spikes.
FAQ & Troubleshooting
Q: "Failed to check for update" error on app launch
The expo-updates native module reads its server URL and runtime version from the native binary, not from app.json at runtime. If the binary was built before edge-ota init wrote the OTA config to app.json, the native module has no valid URL to reach.
Fix: Rebuild the native app so the config is embedded:
npx expo prebuild --clean
# then: expo run:ios, expo run:android, or eas buildQ: Updates work on iOS but not Android
If the Android native project was generated before OTA was configured, AndroidManifest.xml will still contain the default Expo Updates URL (https://u.expo.dev/...) instead of your edge-ota server.
Fix: Regenerate the Android project with npx expo prebuild --clean and rebuild. You can verify by checking android/app/src/main/AndroidManifest.xml for the EXPO_UPDATE_URL meta-data entry — it should point to your edge-ota server, not u.expo.dev.
Q: Updates stopped working after a version bump
If your runtimeVersion uses the "appVersion" policy ({ "policy": "appVersion" }), the runtime version is derived from expo.version. Every time the version number changes, the runtime version changes too — and the server only serves updates matching the exact runtime version the device reports.
Fix: Use edge-ota push --runtime <version> (or comma-separated 1.0.0,1.0.1,1.0.2) to target installed devices without rebuilding. Alternatively, use a static "runtimeVersion": "1.0.0" string in app.json — it stays identical across minor updates so bumping marketing versions never breaks OTA delivery.
Q: How do I verify my native config is correct?
Check these files in your native project to confirm the edge-ota URL and runtime version are embedded:
ios/<Name>/Supporting/Expo.plist EXUpdatesURL should be your edge-ota URL. EXUpdatesRuntimeVersion should match your app.json runtime version.android/app/src/main/AndroidManifest.xml EXPO_UPDATE_URL should be your edge-ota URL (not u.expo.dev). expo_runtime_version in strings.xml should match your app.json runtime version.Q: Should I use a static runtimeVersion or the appVersion policy?
| Approach | Config | When it changes |
|---|---|---|
| Static string | "runtimeVersion": "1.0.0" | Only when you manually edit it |
| appVersion policy | { "policy": "appVersion" } | Every time expo.version bumps |
Recommendation: Use a static string unless you frequently ship native module changes. The appVersion policy is convenient for projects with autoIncrement enabled, but you must remember to push a new OTA update after every version bump.
Q: How do I test OTA on a physical Android device over USB?
When running local testing against a development server on http://localhost:3020, Android physical devices cannot reach your computer without reverse port forwarding:
# Forward device port to host machine
adb reverse tcp:3020 tcp:3020Ensure android:usesCleartextTraffic="true" is set in AndroidManifest.xml for non-HTTPS local testing.