How I Built a Multi-App OTA Update System (And Cut Costs from $199/month to $0)

How I Built a Multi-App OTA Update System (And Cut Costs from $199/month to $0)

# reactnative# expo# selfhosting# infrastructure
How I Built a Multi-App OTA Update System (And Cut Costs from $199/month to $0)Jonathan Mensah

Expo'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.

What Are OTA Updates (And Why They Matter)

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.

The Problem: Expo EAS Update Gets Expensive Fast

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.

Enter Xavia OTA (With Limitations)

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.

What I Changed: Multi-App Support

I forked Xavia OTA and rewrote it to support multiple apps in a single instance.

Key Changes

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()
);
Enter fullscreen mode Exit fullscreen mode

Modified releases to include app_id:

ALTER TABLE releases ADD COLUMN app_id UUID REFERENCES apps(id);
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

My structure (multi-app):

apps/
  {appId}/
    updates/
      {runtimeVersion}/
        {timestamp}.zip
Enter fullscreen mode Exit fullscreen mode

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'];
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Priority 3: Default app If neither works, fall back to a default-app:

app = await database.getAppBySlug('default-app');
Enter fullscreen mode Exit fullscreen mode

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=...
Enter fullscreen mode Exit fullscreen mode

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.

My Setup: Self-Hosted on $5 VPS + Cloudflare R2

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

Architecture Overview

How it works:

  1. Developer runs build script with app-specific upload key

  2. Script bundles JavaScript/assets and uploads to Xavia OTA

  3. Xavia validates upload key, identifies app, stores metadata in PostgreSQL

  4. Update bundle is uploaded to Cloudflare R2

  5. User devices periodically check for updates via manifest endpoint

  6. Xavia queries database to find latest update for that app/runtime version

  7. If update exists, Xavia serves it from R2

  8. Users download and apply update on next app restart

The Deployment Flow

Here's how I push updates from code to users' phones:

1. Build Script

I have a script: ./build-and-publish-app-release.sh

./build-and-publish-app-release.sh <runtimeVersion> <xavia-ota-url> <uploadKey>
Enter fullscreen mode Exit fullscreen mode

What it does:

  1. Extracts the app slug from app.config.js

  2. Gets current git commit hash and message

  3. Runs npx expo export to bundle the JavaScript

  4. Normalizes file paths in metadata (for cross-platform compatibility)

  5. Generates expoconfig.json

  6. Zips everything

  7. Uploads to Xavia OTA with the upload key

Example:

./build-and-publish-app-release.sh 1.0.5 https://ota.myapp.com abc123uploadkey
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

2. React Native App Configuration

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"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The updates.url points to my self-hosted Xavia OTA server instead of Expo's.

3. Update Delivery

When a user opens the app:

  1. App checks https://ota.myserver.com/api/manifest

  2. Xavia OTA identifies the app (via slug or update ID)

  3. Checks if there's a newer update for the runtime version

  4. If yes, downloads and applies it

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

What Problems This Solved

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

Onboarding a New App

Adding a new app is dead simple:

  1. Create app in UI:
*   Name: "My New App"

*   Slug: "mynewapp"

*   Upload key: "some-secret-key-123"
Enter fullscreen mode Exit fullscreen mode
  1. Configure app:
   updates: {
     url: "https://ota.myserver.com/api/manifest"
   }
Enter fullscreen mode Exit fullscreen mode
  1. Deploy updates:
   ./build-and-publish-app-release.sh 1.0.0 https://ota.myserver.com some-secret-key-123
Enter fullscreen mode Exit fullscreen mode

Done. No Docker containers to spin up. No infrastructure changes.

What Breaks (And How I Handle It)

Broken OTA Update

I pushed an update once that crashed on certain Android devices.

The fix:

  1. Noticed crashes in Sentry within minutes

  2. Logged into Xavia OTA dashboard

  3. Rolled back to previous version (one click)

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

Upload Failures

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)

What I'd Do Differently

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

Open Sourcing My Fork

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.

The Bottom Line

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]