
Jonathan MensahExpo's EAS Update service costs $199/month for 50,000 monthly active users. If you have multiple apps...
Expo's EAS Update service costs $199/month for 50,000 monthly active users. If you have multiple apps or are a solo dev with modest traffic, that's expensive, even the $19/month tier only covers 3,000 monthly active users(MAU).
I found an open-source alternative called Xavia OTA, modified it to support multiple apps in a single instance, and self-hosted it on my $5 VPS with Cloudflare R2 storage (which gives 10GB free).
Total cost: $0 for OTA updates across all my apps.
Here's how I built it, what I changed, and how you can do the same.
OTA (Over-The-Air) updates let you push JavaScript/React Native changes to users without going through App Store or Play Store review.
What you can update OTA:
Bug fixes
UI changes
Business logic
API endpoint changes
What requires a full app store update:
Native code changes
New permissions
Native dependency updates
This is crucial for mobile development. Play Store reviews can take days. A critical bug fix via OTA? Minutes.
I consider OTA updates a must-have for every app. The ability to fix bugs or push features without waiting for store approval is too valuable to skip.
Expo's EAS Update pricing:
Free tier: 1,000 monthly active users (MAU), then service stops
Starter: $19/month for 3,000 MAU (~$0.005 per additional MAU)
Production: $199/month for 50,000 MAU
Enterprise: $1,999/month for custom scaling
For my app with ~10,000 active users, I'd need either:
Starter plan ($19/month) + overage fees (~$35/month extra) = ~$54/month
Or jump straight to Production ($199/month)
If you have multiple apps, multiply those costs. For a student or indie dev, that's not sustainable.
I needed a cheaper solution, ideally self-hosted.
I found Xavia OTA, an open-source alternative to Expo's OTA service. It had one major limitation: one app per Docker container.
If you had multiple apps, you needed:
Multiple Docker containers
Multiple databases
Multiple domains/ports
This was expensive (more VPS resources) and annoying to manage.
So I modified it.
I forked Xavia OTA and rewrote it to support multiple apps in a single instance.
1. Database Schema
Added an apps table:
CREATE TABLE apps (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
upload_key TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Modified releases to include app_id:
ALTER TABLE releases ADD COLUMN app_id UUID REFERENCES apps(id);
2. Per-App Upload Keys
Original: Single UPLOAD_KEY environment variable for the entire instance.
My version: Each app has its own upload_key stored in the database.
When you upload an update:
curl -X POST $OTA_URL/api/upload \
-F "file=@update.zip" \
-F "uploadKey=your-app-specific-key" \
-F "appSlug=your-app-slug"
The server looks up the app by uploadKey and validates it before accepting the update.
3. App-Scoped File Storage
Original structure (single app):
updates/
{runtimeVersion}/
{timestamp}.zip
My structure (multi-app):
apps/
{appId}/
updates/
{runtimeVersion}/
{timestamp}.zip
Each app's updates are isolated in its own directory.
4. Smart App Resolution in Manifest Endpoint
The manifest endpoint (/api/manifest) needs to know which app is requesting an update. I implemented a 3-tier resolution strategy:
Priority 1: Explicit header
const appSlug = req.headers['expo-app-slug'];
Priority 2: Infer from update ID The app sends its current expo-current-update-id. I map that to an app slug:
const updateId = req.headers['expo-current-update-id'];
const mapping = await database.getAppSlugByUpdateId(updateId);
Priority 3: Default app If neither works, fall back to a default-app:
app = await database.getAppBySlug('default-app');
This way, updates work seamlessly even without explicit configuration.
5. Per-App Code Signing
Original: Single PRIVATE_KEY_BASE_64 for code signing.
My version: Per-app signing keys:
SIGNING_BECEAPP_PRIVATE_KEY_BASE64=...
SIGNING_WASSCE_PRIVATE_KEY_BASE64=...
Each app can have its own signing certificate for security.
6. Admin UI for Managing Apps
I built a simple UI to:
Create new apps (name, slug, upload key)
View all apps
See releases per app
Copy upload keys
This makes onboarding new apps trivial, no environment variable changes needed.
Infrastructure:
Xavia OTA running on my $5 VPS (same one as my backend)
Cloudflare R2 for storage (10GB free tier)
R2 is S3-compatible, so configuration was straightforward
Why R2?
Free 10GB storage (enough for dozens of app versions)
S3-compatible API (works with existing S3 code)
No egress fees (unlike AWS S3)
Total cost: $0 (R2 free tier + already paying for the VPS anyway).
How it works:
Developer runs build script with app-specific upload key
Script bundles JavaScript/assets and uploads to Xavia OTA
Xavia validates upload key, identifies app, stores metadata in PostgreSQL
Update bundle is uploaded to Cloudflare R2
User devices periodically check for updates via manifest endpoint
Xavia queries database to find latest update for that app/runtime version
If update exists, Xavia serves it from R2
Users download and apply update on next app restart
Here's how I push updates from code to users' phones:
I have a script: ./build-and-publish-app-release.sh
./build-and-publish-app-release.sh <runtimeVersion> <xavia-ota-url> <uploadKey>
What it does:
Extracts the app slug from app.config.js
Gets current git commit hash and message
Runs npx expo export to bundle the JavaScript
Normalizes file paths in metadata (for cross-platform compatibility)
Generates expoconfig.json
Zips everything
Uploads to Xavia OTA with the upload key
Example:
./build-and-publish-app-release.sh 1.0.5 https://ota.myapp.com abc123uploadkey
The script itself:
#!/bin/bash
if [ "$#" -ne 3 ]; then
echo "Usage: $0 <runtimeVersion> <xavia-ota-url> <upload-key>"
exit 1
fi
commitHash=$(git rev-parse HEAD)
commitMessage=$(git log -1 --pretty=%B)
appSlug=$(node scripts/get-slug.js)
runtimeVersion=$1
serverHost=$2
uploadKey=$3
timestamp=$(date -u +%Y%m%d%H%M%S)
outputFolder="./ota-builds/$timestamp"
echo "Output Folder: $outputFolder"
echo "Runtime Version: $runtimeVersion"
echo "App Slug: $appSlug"
echo "Commit Hash: $commitHash"
echo "Commit Message: $commitMessage"
read -p "Do you want to proceed? (y/n): " confirm
if [ "$confirm" != "y" ]; then
echo "Operation cancelled."
exit 1
fi
rm -rf $outputFolder
mkdir -p $outputFolder
npx expo export --output-dir $outputFolder
# Normalize paths in metadata.json
metadataFile="$outputFolder/metadata.json"
if [ -f "$metadataFile" ]; then
node - "$metadataFile" <<'NODE'
const fs = require('fs');
const metadataPath = process.argv[2];
const data = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
const normalize = (value) =>
typeof value === 'string' ? value.replace(/\\/g, '/') : value;
if (data.fileMetadata) {
for (const platform of Object.values(data.fileMetadata)) {
if (platform.bundle) platform.bundle = normalize(platform.bundle);
if (Array.isArray(platform.assets)) {
platform.assets = platform.assets.map((asset) => ({
...asset,
path: normalize(asset.path),
}));
}
}
}
fs.writeFileSync(metadataPath, JSON.stringify(data));
NODE
fi
npx expo config --json > $outputFolder/expoconfig.json
cd $outputFolder
zip -q -r ${timestamp}.zip .
curl -X POST $serverHost/api/upload \
-F "file=@${timestamp}.zip" \
-F "runtimeVersion=$runtimeVersion" \
-F "appSlug=$appSlug" \
-F "commitHash=$commitHash" \
-F "commitMessage=$commitMessage" \
-F "uploadKey=$uploadKey"
echo ""
echo "Uploaded to $serverHost/api/upload"
cd ..
rm -rf $outputFolder
echo "Done"
In my app.config.js:
module.exports = {
expo: {
name: "BECE Past Questions",
slug: "beceapp",
runtimeVersion: {
policy: "appVersion"
},
updates: {
url: "https://xavia-ota.myserver.com/api/manifest",
codeSigningCertificate: "./certs/certificate.pem",
codeSigningMetadata: {
keyid: "main",
alg: "rsa-v1_5-sha256"
}
}
}
}
The updates.url points to my self-hosted Xavia OTA server instead of Expo's.
When a user opens the app:
App checks https://ota.myserver.com/api/manifest
Xavia OTA identifies the app (via slug or update ID)
Checks if there's a newer update for the runtime version
If yes, downloads and applies it
User gets the update on next app restart
Typical flow:
Push code → Run build script → Update live in ~5 minutes
No App Store. No Play Store. Just works.
Before (multiple Docker containers):
Running 3 apps = 3 containers = more RAM/CPU usage on VPS
Managing 3 separate databases
3 different upload keys in environment variables
3 different domains/ports to manage
After (single multi-app instance):
One container handles all apps
One database with app isolation
Upload keys stored per app in DB (no env var changes)
One domain with app-aware routing
Cost savings:
Expo EAS Update (Production plan): $199/month
Or Starter + overages: ~$54/month
My setup: $0/month (using free R2 tier + existing VPS)
Annual savings: $648-$2,388/year
Adding a new app is dead simple:
* Name: "My New App"
* Slug: "mynewapp"
* Upload key: "some-secret-key-123"
updates: {
url: "https://ota.myserver.com/api/manifest"
}
./build-and-publish-app-release.sh 1.0.0 https://ota.myserver.com some-secret-key-123
Done. No Docker containers to spin up. No infrastructure changes.
I pushed an update once that crashed on certain Android devices.
The fix:
Noticed crashes in Sentry within minutes
Logged into Xavia OTA dashboard
Rolled back to previous version (one click)
Users automatically got the stable version
Total downtime: ~15 minutes.
If this had been an App Store update, users would be stuck with a broken app for days.
Sometimes the build script fails to upload (network issues, wrong upload key).
The fix:
Check upload response in terminal
If upload key is wrong: "Invalid upload key" error immediately
If network fails: retry the script (idempotent)
1. Automate backups
Right now, I manually backup the Xavia OTA database. Should automate this.
2. Implement staged rollouts
Currently, updates go to 100% of users immediately. I should add:
10% rollout first
Monitor for crashes for 24 hours
If stable, roll out to 100%
This would've caught that Android bug before it hit everyone.
3. Add deployment notifications
Set up webhooks to notify me (Slack/Discord) when:
New update is uploaded
Rollback happens
Upload fails
I'm currently cleaning up my multi-app fork of Xavia OTA to open source it. Once it's ready, other devs can:
Self-host OTA updates for free
Manage multiple apps in one instance
Save $54-$199/month (depending on app count)
The original Xavia OTA is great, but the multi-app limitation makes it expensive for indie devs managing multiple projects.
You don't need to pay $19-$199/month for OTA updates.
What you need:
A VPS (mine is $5/month, shared with other services)
Cloudflare R2 free tier (10GB storage)
Xavia OTA with multi-app support (my fork, coming soon)
What you get:
Unlimited apps
OTA updates in minutes
One-click rollbacks
Full control
The tradeoff? You maintain it yourself. But if you're already self-hosting your backend (like I am), adding OTA updates is trivial.
Next up: How I Got to 50,000 Downloads Without Spending on Ads (And What Actually Drove Growth)
Written while debugging to: [Yebba– Yellow Eyes]