At first glance, $2,400 might not sound like a lot.
But if you’re building a startup in Africa, you already know what that number represents. It’s runway. It’s margin. It’s flexibility. It’s the difference between pushing a feature now or later. This is the story of how we saved that amount by questioning a decision we were about to make, and how that led us to build a custom OTA (over-the-air) update server for a mobile application serving close to 200,000 users. This wasn’t about being clever or overengineering. It was about understanding our scale, our context, and the tradeoffs we needed to make.
The Problem We Were Heading Toward
By the time I joined the team, the app had already grown significantly. We were serving close to 200,000 users and yet, we didn’t have a proper OTA setup in place.
Every fix meant pushing a new build to the app stores.
Everyhotfixmeant waiting on review cycles.
Every small issue became a bigger delay than it needed to be.
This was actually one of the problems I was brought in to help solve. We knew OTA updates were necessary. The question wasn’t if we needed one it was how we should do it, especially considering where we were operating from and how fast the user base was growing. At that scale, whatever decision we made was going to compound quickly.
Questioning the Default OTA Path
Naturally, the first instinct was to reach for a managed OTA service. That’s what most teams do, and for good reasons: it’s fast to set up, reliable, and removes a lot of operational overhead. But before committing, we paused and looked at the numbers, the traffic patterns, and the realities of our environment. We asked ourselves a few uncomfortable but necessary questions:
- What happens when we start pushing frequent hotfixes to hundreds of thousands of users?
- How does this cost scale over time?
- Are we paying for capabilities we may never need?
- Do we have enough internal capacity to own part of this instead?
This wasn’t about being clever or reinventing the wheel. It was about choosing a solution that made sense long-term, not just one that got us moving quickly. That pause, that moment of questioning the default, is what led us to consider building our own OTA update server.
Why a Custom OTA Update Server Made Sense (For Us)
Building a custom OTA update server wasn’t an obvious decision. It came with real risks.
We had to ask:
- Can we reliably handle update traffic?
- Can we serve update manifests efficiently?
- Can we ensure users don’t get stuck with broken builds?
- Can we operate this without degrading the user experience?
After evaluating our needs and constraints, we realized something important: we didn’t need everything the managed service offered just a reliable way to distribute updates at scale. We already had a working pipeline for handling building and deployment of our new application builds to stores.
So we decided to take ownership of that part of our infrastructure.
The Engineering Split: App vs Infrastructure
This project was very much a collaboration between the Mobile development and DevOps Team
How Expo OTA Updates Work
At a high level, Expo OTA updates work like this:
- The app checks an update URL defined in app configuration.
- The server responds with an update manifest.
- If a newer update is available, the app downloads the bundle and assets.
- The update is cached locally.
- The app applies the update on reload.
Managed Expo services follow this same pattern. The difference here is that we host the update server ourselves.
Client Setup: Expo Application
This section covers the application side of the setup using Expo Updates.
Step 1. Install Expo Updates
expo install expo-updates
Step 2. Configure app.json or app.config.js
Set the updates configuration to point to your custom server.
Example app.json:
{
"expo": {
"updates": {
"url": "https://ota.your-domain.com/updates",
"fallbackToCacheTimeout": 0
}
}
}
This tells Expo where to fetch update manifests from.
Step 3. Enable Runtime Updates
In most cases, Expo handles update checks automatically. If you want manual control, you can trigger checks programmatically.
Example:
import * as Updates from "expo-updates";
export async function checkForUpdates() {
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
} catch (error) {
console.log("Update check failed", error);
}
}
You can run this during app launch or at controlled intervals.
Server Setup: Custom OTA Server
For the server side, we based our work on the Xavia OTA repository. We made a fork of it and made a couple of fixes; the following fixes were made to the original Repo;
- Added platform support to release management.
- Updated manifest tests and API to support platform-specific releases.
- Improved the ReleasesPage UI to display platform information.
- Enhanced rollback functionality.
- Optimized ReleasesPage performance.
- Updated Dockerfile configuration to use a non-root user
- Kubernetes manifest files added
Repository: https://github.com/dew-dr0p/xavia-ota.git
Infrastructure & Deployment
Step 1. Clone the Repository
git https://github.com/dew-dr0p/xavia-ota.git
cd xavia-ota
To deploy locally, follow the steps outlined in the Readme of the Repo. From a production perspective, Kubernetes is a reliable platform to deploy the OTA application. The following resource manifests are in the Kubernetes folder;
- Deployment
- Configmap
- Service
- Secret
- Ingress
Step 2. Modify Secrets and Configuration Values
The following information has to be modified;
- Domain: You will need a domain where the OTA application can be reached
- S3 Compatible Bucket: An s3 compatible storage is needed. You will need the Access Key ID, Secret Access Key and Bucket Name. The manifest is configured using Cloudflare’s R2 storage which is s3 compatible. You will have to create an s3 compatible bucket with the following permission;
- Object Read & Write
- Upload Key: Upload key for publishing updates
- Database Credentials: Input the correct credentials used in creating the database to be used by the OTA application.
- Private Key Base64: You will have to generate your private key which will be used by expo for security signing. It is needed by the app to download the latest update. To create one you can use these commands;
# Generate private key
openssl genrsa -out private-key.pem 2048
# Generate certificate
openssl req -new -x509 -key private-key.pem -out certificate.pem -days 3650
# Convert private key to base64 (for K8s secret)
cat private-key.pem | base64 -w 0 > private-key-base64.txt
Step 3. Deploy Kubernetes Manifest Files
A little experience with Kubernetes will be needed when deploying the manifests. The following resource manifests have to be modified before creating the resource;
- Configmap
- Secrets
- Ingress
Because the Ingress resource references a cert-manager Certificate resource, cert-manager must be properly configured in the cluster. Setting up a ClusterIssuer and securing the Ingress with TLS is outside the scope of this article.
In a production environment, you wouldn’t want the OTA deployment to crash or become slow due to high request volumes. To address this, a Horizontal Pod Autoscaler (HPA) has been configured to automatically scale the number of pods based on expected download traffic.
cd Kubernetes
kubectl create -f .
With the above components properly configured, run the following commands to create the required resources:
Step 4. Generate Update Bundles
Using Expo, you generate update bundles that are compatible with Expo Updates.
Typical flow:
expo export --public-url https://ota.your-domain.com/updates
This generates static bundles and assets that the OTA server can serve.
Step 5. Serve Manifests and Assets
Your OTA server when deployed exposes:
- Update manifests
- JavaScript bundles
- Static assets
The Xavia repo already provides the basic structure for this.
What This Means for Builders in Africa
This experience reinforced something I strongly believe: Building in Africa requires different assumptions. Costs matter more. Infrastructure is less predictable. Margins are tighter. Every architectural decision compounds faster for better or worse. Blindly copying setups from companies operating in completely different environments can quietly hurt your business. Context matters.
When You Shouldn’t Do This
Now, don’t get me wrong. I don’t think this approach makes sense for every startup. For smaller teams, especially early-stage startups, managed services like CodePush or EAS are often the smarter choice. They save time, reduce operational burden, and let teams focus on building the product. If you don’t have someone who can confidently handle deployment, load testing, monitoring, and infrastructure reliability, building a custom OTA server could easily backfire.The last thing you want is a situation where users have a bad experience because your update server becomes overwhelmed or unavailable.
This whole process taught me a lot, especially the importance of looking deeply into problems instead of defaulting to “what everyone else does.” It also sharpened my ability to decide when to rely on third-party services and when it makes sense to take ownership.
Final Thoughts
This wasn’t just about saving money. It was about understanding our scale, respecting our context, and making intentional technical decisions instead of inherited ones. If you’re building a product that’s growing, especially in environments with tighter constraints, I encourage you to periodically question your defaults. Sometimes, the biggest wins aren’t new features; they’re smarter foundations.
If this article interests you, we may follow this up with a deeper technical breakdown of the infrastructure and app-level changes. Would love to hear how others have approached similar challenges.
This article was co-authored by Wisdom-Daniel Efe