<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jonathan Mensah</title>
    <description>The latest articles on DEV Community by Jonathan Mensah (@jonathan_mensah).</description>
    <link>https://dev.to/jonathan_mensah</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3816467%2Fe73179ba-7b7c-4ab1-ac67-27a8acc07924.png</url>
      <title>DEV Community: Jonathan Mensah</title>
      <link>https://dev.to/jonathan_mensah</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonathan_mensah"/>
    <language>en</language>
    <item>
      <title>How I Got to 50,000 Downloads Without Spending on Ads (And What Actually Drove Growth)</title>
      <dc:creator>Jonathan Mensah</dc:creator>
      <pubDate>Tue, 10 Mar 2026 10:11:37 +0000</pubDate>
      <link>https://dev.to/jonathan_mensah/how-i-got-to-50000-downloads-without-spending-on-ads-and-what-actually-drove-growth-1ap0</link>
      <guid>https://dev.to/jonathan_mensah/how-i-got-to-50000-downloads-without-spending-on-ads-and-what-actually-drove-growth-1ap0</guid>
      <description>&lt;p&gt;In 2021, I built my first mobile app during a high school vacation. It was terrible, basically a wrapper around a website for exam past questions. But it made okay money from ads, so I forgot about it for years.&lt;/p&gt;

&lt;p&gt;Fast forward to September 2024: I rebuilt the entire app with React Native. Today, it has over 50,000 downloads across Ghana, Nigeria, and Sierra Leone. I've never spent a dollar on ads.&lt;/p&gt;

&lt;p&gt;Here's exactly how it happened, the channels that worked, the timing that mattered, and the one thing my brother said that changed everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The App: Solving a Real Problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BECE Past Questions&lt;/strong&gt; helps students in Ghana prepare for the Basic Education Certificate Examination (BECE).&lt;/p&gt;

&lt;p&gt;The problem it solves is simple but real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Students need past exam questions to practice&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Physical past question books cost money&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Those books are often outdated&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Digital alternatives barely existed&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an app that gives students free access to all BECE past questions, plus quizzes to help them prepare.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key lesson:&lt;/strong&gt; I found a niche problem and solved it. This matters more than most people realize.&lt;/p&gt;

&lt;p&gt;I've built other apps since then. They all failed. Why? Competition was fierce, and competitors had marketing budgets. If you're a student or solo dev without ad spend, you'll sink in competitive markets.&lt;/p&gt;

&lt;p&gt;But BECE exam prep? That was wide open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Failed First Version (2021-2024)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first version was bad. Really bad.&lt;/p&gt;

&lt;p&gt;It was a glorified webview, just a wrapper around a website. The UI was clunky. The experience was poor. Downloads were decent, but &lt;strong&gt;churn was terrible.&lt;/strong&gt; People would download it, use it once, and never come back.&lt;/p&gt;

&lt;p&gt;I made okay money from AdMob, so I left it alone and moved on to other projects.&lt;/p&gt;

&lt;p&gt;For three years, that app just sat there, slowly accumulating bad reviews.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Rebuild: September 2024&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In September 2024, I decided to do a complete overhaul.&lt;/p&gt;

&lt;p&gt;I rebuilt the app from scratch in React Native:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Proper offline support&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Better UI/UX&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Quiz features&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Faster performance&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cleaner store listing&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I updated the Play Store screenshots, rewrote the description, and relaunched.&lt;/p&gt;

&lt;p&gt;Then I waited for users to pour in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They didn't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I got a bit discouraged. I'd just spent weeks rebuilding this app, and downloads were... the same.&lt;/p&gt;

&lt;p&gt;That's when my brother said something that changed everything:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Jonathan, no one knows about the app. How do you expect people to download it?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;He was right. I had built it. Now I needed to actually tell people it existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Growth Strategy (That Wasn't Really a Strategy)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My brother started posting the app link in Facebook groups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What he posted:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Simple message: "This app gives you access to all BECE past questions for free"&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Link to the Play Store&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Posted in student groups across Ghana&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How often:&lt;/strong&gt; Regularly, but not spammy enough to get flagged by Facebook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt; We started seeing downloads. Sometimes 100-200 per day. During exam season? &lt;strong&gt;500+ downloads per day.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That was it. That was the entire "marketing strategy."&lt;/p&gt;

&lt;p&gt;No paid ads. No influencer outreach. No TikTok videos. Just my brother dropping links in Facebook groups where students actually were.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Actually Drove Growth: Four Things&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Accidental ASO (App Store Optimization)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I set up the store listing, I didn't know anything about ASO. I just named it &lt;strong&gt;"BECE Past Questions"&lt;/strong&gt; and wrote a description focused on what students were searching for.&lt;/p&gt;

&lt;p&gt;Turns out, that was perfect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Today, I rank top 3 when you search "BECE" in the Play Store.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't intentionally optimize for keywords. I just named the app what students would search for. That accidental SEO has been my biggest growth driver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Sometimes the best ASO is just being obvious. Name your app what people are searching for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Word of Mouth (The Compounding Effect)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Students tell other students. Teachers recommend it. Parents share it.&lt;/p&gt;

&lt;p&gt;I know this because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Users tell me directly: "My friend told me about this app"&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Teachers and parents &lt;strong&gt;call me&lt;/strong&gt; to thank me (my number is in the app for support)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reviews mention: "My teacher recommended this"&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Word of mouth didn't happen overnight. It took time. But once it started, it compounded.&lt;/p&gt;

&lt;p&gt;The 2025 spike (44,000+ downloads) wasn't because I suddenly started marketing. It was because enough people knew about the app that it spread organically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Seasonal Timing (Exam Cycles)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My app follows a predictable pattern:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;colgroup&gt;
&lt;col&gt;
&lt;col&gt;
&lt;/colgroup&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Month&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Installs&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Jan-Apr&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Growth begins&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;May-Jun&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Peak season&lt;/strong&gt; (7,500+ downloads/month)&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Jul-Aug&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Drop after exams&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Sep-Dec&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Low activity&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Example from 2025:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;January: 2,857 downloads&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;May: &lt;strong&gt;7,520 downloads&lt;/strong&gt; (peak)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;July: 2,019 downloads (post-exam drop)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This seasonal pattern is actually &lt;strong&gt;good&lt;/strong&gt;. It means the product perfectly fits the exam cycle. Students need it when exams are approaching, not year-round.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; If your product is seasonal, lean into it. Don't fight the cycle, optimize for the peaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Community Building (The WhatsApp Groups)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After the 5th iteration of the app, I added a WhatsApp group link for students to study together.&lt;/p&gt;

&lt;p&gt;I did this on a whim. I didn't expect much.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Today, there are 5 WhatsApp groups with ~5,000 students total&lt;/strong&gt; (WhatsApp limits groups to 1,000 members).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens in these groups:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Students help each other with past questions&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;They ask questions about specific exams&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I jump in occasionally to help&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;New users join the app because they're in the groups&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The moderation problem:&lt;/strong&gt; Managing 5,000 teenagers is... challenging.&lt;/p&gt;

&lt;p&gt;So I built a &lt;strong&gt;WhatsApp bot&lt;/strong&gt; that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Removes inappropriate content&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Steers conversations back to studying&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Moderates automatically&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These groups create a community around the app. They keep students engaged, help with retention, and generate word-of-mouth growth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Community isn't just for SaaS products. Even a mobile app can benefit from giving users a place to interact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Review Recovery Strategy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I rebuilt the app, I had a problem: &lt;strong&gt;tons of bad reviews from the old version.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The old app was terrible, and users left 1-star reviews. Those reviews stuck around even after the rebuild.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I fixed it:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Created a review funnel&lt;/strong&gt; I showed the Play Store review dialog &lt;strong&gt;after users completed something rewarding&lt;/strong&gt;, like finishing a quiz or completing a set of questions.&lt;/p&gt;

&lt;p&gt;Psychology: People are more likely to leave positive reviews when they've just experienced success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Responded to every bad review&lt;/strong&gt; I replied to complaints, explained what I'd fixed, and asked users to reconsider their rating.&lt;/p&gt;

&lt;p&gt;Some updated their reviews. Many didn't. But new positive reviews eventually outnumbered the old bad ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Rating climbed from ~2.5 stars to &lt;strong&gt;4.3 stars&lt;/strong&gt; with hundreds of positive reviews.&lt;/p&gt;

&lt;p&gt;Teachers and parents now call me to thank me. One parent told me the app helped their child pass BECE. That's worth more than any 5-star review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 2025 Breakout: 9× Growth&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;2024: ~5,000 downloads 2025: &lt;strong&gt;44,000+ downloads&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;9× growth in one year.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changed?&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The React Native rebuild (better product)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Improved Play Store listing (better screenshots, description)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Facebook group posts (initial traction)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Word of mouth compounding (organic growth)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;WhatsApp community (retention and engagement)&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But honestly? The biggest factor was &lt;strong&gt;timing + product-market fit.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Students needed this. It solved a real problem. And once enough people knew about it, it spread on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geographic Expansion (Without Trying)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I built this app for Ghana. That's where BECE exams happen.&lt;/p&gt;

&lt;p&gt;But look at my user base now:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;colgroup&gt;
&lt;col&gt;
&lt;col&gt;
&lt;/colgroup&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Country&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Share&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;🇬🇭 Ghana&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;~70%&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;🇳🇬 Nigeria&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;~25%&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;🇸🇱 Sierra Leone&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;~5%&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Nigeria is growing fast:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;2022: ~2,167 downloads&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;2025: &lt;strong&gt;~7,980 downloads&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nigeria has &lt;strong&gt;10× Ghana's population&lt;/strong&gt; and it’s already my second largest market at 25% of users.&lt;/p&gt;

&lt;p&gt;Sierra Leone is small but consistent: &lt;strong&gt;600+ downloads per month.&lt;/strong&gt; For a country that size, that's significant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Build for one specific market, but don't geo-block. You might discover adjacent markets you didn't know existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Didn't Work (And What I Never Tried)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I didn't try:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Twitter/X posts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;TikTok videos&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Instagram ads&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Influencer outreach&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reddit posts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Paid ads (Google, Facebook)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why? Honestly, I just didn't think about it. My brother handled the Facebook groups, and that worked, so I never explored other channels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What failed:&lt;/strong&gt; I built a game app recently (&lt;strong&gt;Z Play&lt;/strong&gt; - mini games). It's not doing well.&lt;/p&gt;

&lt;p&gt;Why? &lt;strong&gt;The market is too competitive.&lt;/strong&gt; Gaming apps have massive competition, and competitors have real marketing budgets. Without ads or a unique angle, you sink.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Niche &amp;gt; competitive. A small, underserved market beats a huge, competitive one, especially if you have no budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd Do Differently&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Start the Facebook groups earlier&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I wasted three years with a bad app sitting idle. If I'd rebuilt sooner and started promoting it earlier, I'd be at 100K+ downloads by now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Respond to reviews faster&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I eventually responded to bad reviews, but it took time. Responding immediately would've helped the rating recover faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Build the WhatsApp community from day one&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The groups are now a core part of user retention. I should've added that feature in version 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Track metrics from the start&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't pay attention to where downloads were coming from until recently. If I'd tracked it earlier, I could've doubled down on what worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's Next&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For BECE Past Questions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Keep improving based on user feedback&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Update with new past questions every year&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Maintain the WhatsApp community&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;New project: WASSCE Past Questions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm building a separate app for WASSCE (West African Senior School Certificate Examination).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The challenge:&lt;/strong&gt; Collecting past questions for WASSCE is &lt;strong&gt;exponentially harder&lt;/strong&gt; than BECE. There are more subjects and more years&lt;/p&gt;

&lt;p&gt;Right now, I'm deep in the data collection phase. It's the most time-intensive part of apps like this.&lt;/p&gt;

&lt;p&gt;I'll write about that experience in a future post, how I scrape, collect, and process exam questions at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Bottom Line&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;50,000 downloads. $0 spent on ads.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what actually worked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Solved a real, niche problem&lt;/strong&gt; (free access to past questions)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Accidental ASO&lt;/strong&gt; (named the app what students search for)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook groups&lt;/strong&gt; (thanks to my brother)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Word of mouth&lt;/strong&gt; (compounded over time)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Seasonal timing&lt;/strong&gt; (aligned with exam cycles)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Community building&lt;/strong&gt; (WhatsApp groups kept users engaged)&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What didn't matter:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Fancy marketing strategies&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Social media presence&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Paid ads&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Influencers&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sometimes growth isn't about doing everything. It's about doing the &lt;strong&gt;one or two things that actually matter&lt;/strong&gt; for your specific product and market.&lt;/p&gt;

&lt;p&gt;For me, that was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Building something students actually needed&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Showing up where they already were (Facebook groups)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Letting word of mouth do the rest&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're a student or solo dev without a marketing budget, find a niche problem, solve it well, and show up where your users are. The rest will follow.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; How I'm collecting and processing WASSCE past questions at scale (and why it's 10× harder than BECE).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written while debugging to: [Dunsin Oyekan – Nagode]&lt;/em&gt;&lt;/p&gt;




</description>
      <category>mobile</category>
      <category>startup</category>
      <category>bootstrap</category>
      <category>marketing</category>
    </item>
    <item>
      <title>How I Built a Multi-App OTA Update System (And Cut Costs from $199/month to $0)</title>
      <dc:creator>Jonathan Mensah</dc:creator>
      <pubDate>Tue, 10 Mar 2026 10:09:39 +0000</pubDate>
      <link>https://dev.to/jonathan_mensah/how-i-built-a-multi-app-ota-update-system-and-cut-costs-from-199month-to-0-2m7g</link>
      <guid>https://dev.to/jonathan_mensah/how-i-built-a-multi-app-ota-update-system-and-cut-costs-from-199month-to-0-2m7g</guid>
      <description>&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;Total cost: $0 for OTA updates across all my apps.&lt;/p&gt;

&lt;p&gt;Here's how I built it, what I changed, and how you can do the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are OTA Updates (And Why They Matter)
&lt;/h2&gt;

&lt;p&gt;OTA (Over-The-Air) updates let you push JavaScript/React Native changes to users &lt;strong&gt;without going through App Store or Play Store review.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you can update OTA:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Bug fixes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;UI changes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Business logic&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;API endpoint changes&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What requires a full app store update:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Native code changes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;New permissions&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Native dependency updates&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is crucial for mobile development. Play Store reviews can take days. A critical bug fix via OTA? &lt;strong&gt;Minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I consider OTA updates a &lt;strong&gt;must-have for every app.&lt;/strong&gt; The ability to fix bugs or push features without waiting for store approval is too valuable to skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Expo EAS Update Gets Expensive Fast
&lt;/h2&gt;

&lt;p&gt;Expo's EAS Update pricing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Free tier:&lt;/strong&gt; 1,000 monthly active users (MAU), then service stops&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Starter:&lt;/strong&gt; $19/month for 3,000 MAU (~$0.005 per additional MAU)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Production:&lt;/strong&gt; $199/month for 50,000 MAU&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enterprise:&lt;/strong&gt; $1,999/month for custom scaling&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For my app with ~10,000 active users, I'd need either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Starter plan ($19/month) + overage fees (~$35/month extra) = ~$54/month&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Or jump straight to Production ($199/month)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have multiple apps, multiply those costs. For a student or indie dev, that's not sustainable.&lt;/p&gt;

&lt;p&gt;I needed a cheaper solution, ideally self-hosted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter Xavia OTA (With Limitations)
&lt;/h2&gt;

&lt;p&gt;I found &lt;a href="https://github.com/xavia-io/xavia-ota" rel="noopener noreferrer"&gt;Xavia OTA&lt;/a&gt;, an open-source alternative to Expo's OTA service. It had one major limitation: &lt;strong&gt;one app per Docker container.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you had multiple apps, you needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Multiple Docker containers&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Multiple databases&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Multiple domains/ports&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This was expensive (more VPS resources) and annoying to manage.&lt;/p&gt;

&lt;p&gt;So I modified it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Changed: Multi-App Support
&lt;/h2&gt;

&lt;p&gt;I forked Xavia OTA and rewrote it to support &lt;strong&gt;multiple apps in a single instance.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Changes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Database Schema&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Added an &lt;code&gt;apps&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;apps&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;upload_key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modified &lt;code&gt;releases&lt;/code&gt; to include &lt;code&gt;app_id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;releases&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;apps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Per-App Upload Keys&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Original: Single &lt;code&gt;UPLOAD_KEY&lt;/code&gt; environment variable for the entire instance.&lt;/p&gt;

&lt;p&gt;My version: Each app has its own &lt;code&gt;upload_key&lt;/code&gt; stored in the database.&lt;/p&gt;

&lt;p&gt;When you upload an update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nv"&gt;$OTA_URL&lt;/span&gt;/api/upload &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@update.zip"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"uploadKey=your-app-specific-key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"appSlug=your-app-slug"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server looks up the app by &lt;code&gt;uploadKey&lt;/code&gt; and validates it before accepting the update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. App-Scoped File Storage&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Original structure (single app):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;updates/
  {runtimeVersion}/
    {timestamp}.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My structure (multi-app):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apps/
  {appId}/
    updates/
      {runtimeVersion}/
        {timestamp}.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each app's updates are isolated in its own directory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Smart App Resolution in Manifest Endpoint&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The manifest endpoint (&lt;code&gt;/api/manifest&lt;/code&gt;) needs to know which app is requesting an update. I implemented a 3-tier resolution strategy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Priority 1: Explicit header&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-app-slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Priority 2: Infer from update ID&lt;/strong&gt; The app sends its current &lt;code&gt;expo-current-update-id&lt;/code&gt;. I map that to an app slug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updateId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-current-update-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAppSlugByUpdateId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updateId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Priority 3: Default app&lt;/strong&gt; If neither works, fall back to a &lt;code&gt;default-app&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAppBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default-app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way, updates work seamlessly even without explicit configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Per-App Code Signing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Original: Single &lt;code&gt;PRIVATE_KEY_BASE_64&lt;/code&gt; for code signing.&lt;/p&gt;

&lt;p&gt;My version: Per-app signing keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SIGNING_BECEAPP_PRIVATE_KEY_BASE64=...
SIGNING_WASSCE_PRIVATE_KEY_BASE64=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each app can have its own signing certificate for security.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Admin UI for Managing Apps&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I built a simple UI to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Create new apps (name, slug, upload key)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;View all apps&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;See releases per app&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Copy upload keys&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes onboarding new apps trivial, no environment variable changes needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Setup: Self-Hosted on $5 VPS + Cloudflare R2
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Xavia OTA running on my $5 VPS (same one as my backend)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cloudflare R2 for storage (10GB free tier)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;R2 is S3-compatible, so configuration was straightforward&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why R2?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Free 10GB storage (enough for dozens of app versions)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;S3-compatible API (works with existing S3 code)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No egress fees (unlike AWS S3)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total cost: &lt;strong&gt;$0&lt;/strong&gt; (R2 free tier + already paying for the VPS anyway).&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Folx25lfm5xm2xkpgkfee.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Folx25lfm5xm2xkpgkfee.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Developer runs build script with app-specific upload key&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Script bundles JavaScript/assets and uploads to Xavia OTA&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Xavia validates upload key, identifies app, stores metadata in PostgreSQL&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Update bundle is uploaded to Cloudflare R2&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;User devices periodically check for updates via manifest endpoint&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Xavia queries database to find latest update for that app/runtime version&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If update exists, Xavia serves it from R2&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Users download and apply update on next app restart&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Deployment Flow
&lt;/h2&gt;

&lt;p&gt;Here's how I push updates from code to users' phones:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Build Script
&lt;/h3&gt;

&lt;p&gt;I have a script: &lt;code&gt;./build-and-publish-app-release.sh&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build-and-publish-app-release.sh &amp;lt;runtimeVersion&amp;gt; &amp;lt;xavia-ota-url&amp;gt; &amp;lt;uploadKey&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Extracts the app slug from &lt;code&gt;app.config.js&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Gets current git commit hash and message&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Runs &lt;code&gt;npx expo export&lt;/code&gt; to bundle the JavaScript&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Normalizes file paths in metadata (for cross-platform compatibility)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Generates &lt;code&gt;expoconfig.json&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Zips everything&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Uploads to Xavia OTA with the upload key&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build-and-publish-app-release.sh 1.0.5 https://ota.myapp.com abc123uploadkey
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$#"&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 3 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Usage: &lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt; &amp;lt;runtimeVersion&amp;gt; &amp;lt;xavia-ota-url&amp;gt; &amp;lt;upload-key&amp;gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;commitHash&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse HEAD&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;commitMessage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git log &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;%B&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;appSlug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;node scripts/get-slug.js&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;runtimeVersion&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
&lt;span class="nv"&gt;serverHost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;
&lt;span class="nv"&gt;uploadKey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;

&lt;span class="nv"&gt;timestamp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%d%H%M%S&lt;span class="si"&gt;)&lt;/span&gt; 
&lt;span class="nv"&gt;outputFolder&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"./ota-builds/&lt;/span&gt;&lt;span class="nv"&gt;$timestamp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Output Folder: &lt;/span&gt;&lt;span class="nv"&gt;$outputFolder&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Runtime Version: &lt;/span&gt;&lt;span class="nv"&gt;$runtimeVersion&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"App Slug: &lt;/span&gt;&lt;span class="nv"&gt;$appSlug&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Commit Hash: &lt;/span&gt;&lt;span class="nv"&gt;$commitHash&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Commit Message: &lt;/span&gt;&lt;span class="nv"&gt;$commitMessage&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Do you want to proceed? (y/n): "&lt;/span&gt; confirm

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$confirm&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"y"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Operation cancelled."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="nv"&gt;$outputFolder&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$outputFolder&lt;/span&gt;

npx expo &lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nt"&gt;--output-dir&lt;/span&gt; &lt;span class="nv"&gt;$outputFolder&lt;/span&gt;

&lt;span class="c"&gt;# Normalize paths in metadata.json&lt;/span&gt;
&lt;span class="nv"&gt;metadataFile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$outputFolder&lt;/span&gt;&lt;span class="s2"&gt;/metadata.json"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$metadataFile&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;node - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$metadataFile&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;NODE&lt;/span&gt;&lt;span class="sh"&gt;'
const fs = require('fs');
const metadataPath = process.argv[2];
const data = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
const normalize = (value) =&amp;gt;
  typeof value === 'string' ? value.replace(/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;/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) =&amp;gt; ({
        ...asset,
        path: normalize(asset.path),
      }));
    }
  }
}
fs.writeFileSync(metadataPath, JSON.stringify(data));
&lt;/span&gt;&lt;span class="no"&gt;NODE
&lt;/span&gt;&lt;span class="k"&gt;fi

&lt;/span&gt;npx expo config &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$outputFolder&lt;/span&gt;/expoconfig.json

&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$outputFolder&lt;/span&gt;  
zip &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;timestamp&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.zip &lt;span class="nb"&gt;.&lt;/span&gt;

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nv"&gt;$serverHost&lt;/span&gt;/api/upload &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;timestamp&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.zip"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"runtimeVersion=&lt;/span&gt;&lt;span class="nv"&gt;$runtimeVersion&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"appSlug=&lt;/span&gt;&lt;span class="nv"&gt;$appSlug&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"commitHash=&lt;/span&gt;&lt;span class="nv"&gt;$commitHash&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"commitMessage=&lt;/span&gt;&lt;span class="nv"&gt;$commitMessage&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"uploadKey=&lt;/span&gt;&lt;span class="nv"&gt;$uploadKey&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Uploaded to &lt;/span&gt;&lt;span class="nv"&gt;$serverHost&lt;/span&gt;&lt;span class="s2"&gt;/api/upload"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ..
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="nv"&gt;$outputFolder&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Done"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. React Native App Configuration
&lt;/h3&gt;

&lt;p&gt;In my &lt;code&gt;app.config.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;expo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BECE Past Questions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;beceapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;runtimeVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;appVersion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://xavia-ota.myserver.com/api/manifest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;codeSigningCertificate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./certs/certificate.pem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;codeSigningMetadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;keyid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;main&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rsa-v1_5-sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;updates.url&lt;/code&gt; points to my self-hosted Xavia OTA server instead of Expo's.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Update Delivery
&lt;/h3&gt;

&lt;p&gt;When a user opens the app:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;App checks &lt;code&gt;https://ota.myserver.com/api/manifest&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Xavia OTA identifies the app (via slug or update ID)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Checks if there's a newer update for the runtime version&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If yes, downloads and applies it&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;User gets the update on next app restart&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Typical flow:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Push code → Run build script → Update live in ~5 minutes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No App Store. No Play Store. Just works.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Problems This Solved
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before (multiple Docker containers):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Running 3 apps = 3 containers = more RAM/CPU usage on VPS&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Managing 3 separate databases&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;3 different upload keys in environment variables&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;3 different domains/ports to manage&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After (single multi-app instance):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;One container handles all apps&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One database with app isolation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Upload keys stored per app in DB (no env var changes)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One domain with app-aware routing&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cost savings:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Expo EAS Update (Production plan): $199/month&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Or Starter + overages: ~$54/month&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;My setup: $0/month (using free R2 tier + existing VPS)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Annual savings:&lt;/strong&gt; $648-$2,388/year&lt;/p&gt;

&lt;h2&gt;
  
  
  Onboarding a New App
&lt;/h2&gt;

&lt;p&gt;Adding a new app is dead simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Create app in UI:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*   Name: "My New App"

*   Slug: "mynewapp"

*   Upload key: "some-secret-key-123"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Configure app:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;   &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://ota.myserver.com/api/manifest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Deploy updates:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   ./build-and-publish-app-release.sh 1.0.0 https://ota.myserver.com some-secret-key-123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done. No Docker containers to spin up. No infrastructure changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Breaks (And How I Handle It)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Broken OTA Update
&lt;/h3&gt;

&lt;p&gt;I pushed an update once that crashed on certain Android devices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Noticed crashes in Sentry within minutes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Logged into Xavia OTA dashboard&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Rolled back to previous version (one click)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Users automatically got the stable version&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total downtime: ~15 minutes.&lt;/p&gt;

&lt;p&gt;If this had been an App Store update, users would be stuck with a broken app for days.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upload Failures
&lt;/h3&gt;

&lt;p&gt;Sometimes the build script fails to upload (network issues, wrong upload key).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Check upload response in terminal&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If upload key is wrong: "Invalid upload key" error immediately&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If network fails: retry the script (idempotent)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Automate backups&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right now, I manually backup the Xavia OTA database. Should automate this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Implement staged rollouts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Currently, updates go to 100% of users immediately. I should add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;10% rollout first&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Monitor for crashes for 24 hours&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If stable, roll out to 100%&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This would've caught that Android bug before it hit everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Add deployment notifications&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Set up webhooks to notify me (Slack/Discord) when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;New update is uploaded&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Rollback happens&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Upload fails&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Open Sourcing My Fork
&lt;/h2&gt;

&lt;p&gt;I'm currently cleaning up my multi-app fork of Xavia OTA to open source it. Once it's ready, other devs can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Self-host OTA updates for free&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Manage multiple apps in one instance&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Save $54-$199/month (depending on app count)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original Xavia OTA is great, but the multi-app limitation makes it expensive for indie devs managing multiple projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;You don't need to pay $19-$199/month for OTA updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you need:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A VPS (mine is $5/month, shared with other services)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cloudflare R2 free tier (10GB storage)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Xavia OTA with multi-app support (my fork, coming soon)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Unlimited apps&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;OTA updates in minutes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One-click rollbacks&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Full control&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff? You maintain it yourself. But if you're already self-hosting your backend (like I am), adding OTA updates is trivial.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; &lt;a href="https://dev.to/jonathan_mensah/how-i-got-to-50000-downloads-without-spending-on-ads-and-what-actually-drove-growth-1ap0"&gt;How I Got to 50,000 Downloads Without Spending on Ads (And What Actually Drove Growth)&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written while debugging to: [Yebba– Yellow Eyes]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>selfhosting</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>How I Handle Deployments and CI/CD for a $5/Month Production App</title>
      <dc:creator>Jonathan Mensah</dc:creator>
      <pubDate>Tue, 10 Mar 2026 10:06:32 +0000</pubDate>
      <link>https://dev.to/jonathan_mensah/how-i-handle-deployments-and-cicd-for-a-5month-production-app-27ee</link>
      <guid>https://dev.to/jonathan_mensah/how-i-handle-deployments-and-cicd-for-a-5month-production-app-27ee</guid>
      <description>&lt;p&gt;Running a production app with ~10,000 active users on a $5/month VPS sounds impossible. But it's not, if you're smart about your tooling.&lt;/p&gt;

&lt;p&gt;Here's my entire deployment setup: from code commit to production, backend to mobile OTA updates, and how I handle rollbacks when things break.&lt;/p&gt;

&lt;p&gt;Spoiler: I didn't compromise on automation. I just stopped paying for things I could self-host.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;$5/month VPS (2 vCPU, 4GB RAM + swap memory)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;GitHub (free tier)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dokploy (self-hosted, free)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Xavia OTA (self-hosted, open source)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;MongoDB Atlas (free tier)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cloudflare R2 (free tier)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total monthly cost:&lt;/strong&gt; $5. That's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backend Deployment: Dokploy Does Everything
&lt;/h2&gt;

&lt;p&gt;I used to manually SSH into my VPS, pull code, restart services, and pray nothing broke. Now I use &lt;strong&gt;Dokploy&lt;/strong&gt;—a self-hosted alternative to platforms like Railway or Render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Dokploy does:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Listens for commits on my GitHub repo&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Automatically builds the NestJS backend&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deploys with zero-downtime (spins up new instance, switches traffic, kills old instance)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Shows deployment status in a dashboard&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The setup:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Install Dokploy on my VPS&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Connect it to my GitHub repo&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Configure build settings (Node.js, environment variables, ports)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Done&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every time I push to &lt;code&gt;main&lt;/code&gt;, Dokploy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Pulls the latest code&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Installs dependencies&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Builds the app&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deploys it&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Notifies me if it fails&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No GitHub Actions minutes consumed. No paying for CI/CD platforms. Just a self-hosted tool doing exactly what I need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I know if deployment failed:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Dokploy dashboard shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Build logs in real-time&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deployment status (success/failed)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Current running version&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Resource usage&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If something breaks, I see it immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile Deployment: Self-Hosted OTA Updates
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting.&lt;/p&gt;

&lt;p&gt;I used to pay for Expo's OTA update service. Then I found &lt;strong&gt;Xavia OTA&lt;/strong&gt;, an open-source alternative I could self-host.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Are OTA (Over-The-Air) Updates?
&lt;/h3&gt;

&lt;p&gt;For those unfamiliar: OTA updates let you push JavaScript/React Native changes to users &lt;strong&gt;without going through the App Store or Play Store review process.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What can be updated OTA:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Bug fixes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;UI changes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Business logic&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;API endpoint changes&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What cannot be updated OTA:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Native code changes (requires a full app store update)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;New permissions&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Version bumps that change native dependencies&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is crucial for mobile apps. Play Store reviews can take days. OTA updates deploy in minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I Set Up Xavia OTA
&lt;/h3&gt;

&lt;p&gt;Xavia OTA had one limitation: it only supported &lt;strong&gt;one app per instance&lt;/strong&gt;. If you had multiple apps, you needed multiple Docker containers.&lt;/p&gt;

&lt;p&gt;I tinkered with the code and made it support &lt;strong&gt;multiple apps in a single instance&lt;/strong&gt;. Now I can host updates for multiple projects without spinning up separate containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deployment process:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I have a script: &lt;code&gt;./build-and-publish-app-release.sh&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build-and-publish-app-release.sh &amp;lt;runtimeVersion&amp;gt; &amp;lt;xavia-ota-url&amp;gt; &amp;lt;uploadKey&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Builds the React Native bundle&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Creates an update manifest&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Uploads it to my self-hosted Xavia OTA server&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Users get the update next time they open the app&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build-and-publish-app-release.sh 1.2.3 https://ota.myapp.com abc123key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within minutes, all users on runtime version &lt;code&gt;1.2.3&lt;/code&gt; get the update. No app store. No waiting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version Compatibility Strategy
&lt;/h3&gt;

&lt;p&gt;Here's the key: &lt;strong&gt;all my backend APIs are backward compatible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;New app versions can talk to old backend APIs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Old app versions can talk to new backend APIs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I never force users to update immediately&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This prevents the nightmare scenario where a backend update breaks older app versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Updates: Automated Past Questions Sync
&lt;/h2&gt;

&lt;p&gt;My app serves exam past questions. These get updated regularly.&lt;/p&gt;

&lt;p&gt;I have an automated script that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Listens for new past questions (scraped or manually added)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Automatically updates MongoDB with new content&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Runs on a cron job&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Users get fresh content without me manually updating the database every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Breaks (And How I Handle It)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The OTA Update That Crashed Everything
&lt;/h3&gt;

&lt;p&gt;I pushed an OTA update once. Within minutes, users started reporting crashes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; I had introduced a bug that only appeared on certain Android devices (classic React Native moment).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because I control the OTA server, I just rolled back the update:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Removed the broken update from Xavia OTA&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Re-published the previous stable version&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Users automatically got the working version&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total downtime: ~15 minutes.&lt;/p&gt;

&lt;p&gt;If this had been an app store update, I would've been screwed. Users would be stuck with a broken app for days while the fix went through review.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend Deployment Failures
&lt;/h3&gt;

&lt;p&gt;Dokploy's dashboard shows me immediately if a deployment fails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Build errors (syntax error, missing dependencies)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Runtime errors (crashes on startup)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When this happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Check the logs in Dokploy&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fix the issue locally&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Push the fix&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dokploy auto-deploys the corrected version&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the new deployment is completely broken, I can roll back to the previous version with one click in the Dokploy dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring and Health Checks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Backend monitoring:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Dokploy shows resource usage (CPU, RAM, network)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I have Sentry for error tracking (from my previous post)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Simple HTTP health check endpoint (&lt;code&gt;/health&lt;/code&gt;)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Mobile monitoring:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Sentry for crash reporting&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Analytics for user sessions&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;OTA update adoption rate (I can see how many users are on each version)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If something's wrong, I know within minutes, not hours or days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The $5/Month Reality Check
&lt;/h2&gt;

&lt;p&gt;People ask: "How can you run production on a $5 VPS?"&lt;/p&gt;

&lt;p&gt;Here's the secret: &lt;strong&gt;A 2 vCPU, 4GB RAM VPS is plenty for most apps.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I run on this VPS:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;NestJS backend&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dokploy (for deployments)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Xavia OTA (for mobile updates)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Nginx (reverse proxy)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Automated scripts (cron jobs for content updates)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The trick:&lt;/strong&gt; I added swap memory to handle occasional spikes. The VPS rarely uses more than 60% of its resources under normal load.&lt;/p&gt;

&lt;p&gt;I might upgrade when I deploy more applications, but for now, it's more than enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Didn't Have to Compromise On
&lt;/h2&gt;

&lt;p&gt;This is important: &lt;strong&gt;I didn't compromise on automation because of the budget.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I still have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Automated deployments (Dokploy)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;OTA updates (Xavia OTA)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Zero-downtime deploys (Dokploy handles this)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Rollback capability (one click)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Real-time deployment logs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Error tracking (Sentry)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only difference? I self-host the tools instead of paying for SaaS platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I saved by self-hosting:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;GitHub Actions: $0 (would be ~$10-20/month for private repos with heavy CI usage)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Expo OTA: $0 (would be $29/month)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Railway/Render: $0 (would be $20-50/month)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Total savings: ~$60-100/month&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Set up automated backups earlier&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I should've automated MongoDB backups from day one. I do manual exports weekly now, but this should be automated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Add deployment notifications&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Dokploy doesn't notify me on success/failure. I should set up a webhook to Slack or Discord so I know immediately when deployments happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Implement staged rollouts for OTA updates&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right now, OTA updates go to all users at once. I should implement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;10% of users get the update first&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If no crashes/errors after 24 hours, roll out to 100%&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This would've caught that Android crash before it hit everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;You don't need expensive DevOps tools to run a production app. You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A decent VPS ($5/month is enough)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Self-hosted CI/CD (Dokploy)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Self-hosted OTA updates (Xavia OTA)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Backward-compatible APIs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A rollback strategy&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I went from paying ~$100/month (Expo OTA + managed hosting + CI/CD) to $5/month, without losing any automation or deployment speed.&lt;/p&gt;

&lt;p&gt;The tradeoff? You have to set up and maintain the tools yourself. But if you're a solo dev or a student, that's a tradeoff worth making.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; &lt;a href="https://dev.to/jonathan_mensah/how-i-built-a-multi-app-ota-update-system-and-cut-costs-from-199month-to-0-2m7g"&gt;How I built a multi-app OTA update system for $0/month (and saved $199/month by ditching Expo's service).&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written while debugging to: [Dunsin Oyekan– Nagode]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>cicd</category>
      <category>devops</category>
      <category>tooling</category>
    </item>
    <item>
      <title>How I Migrated 50,000 Users from Firebase to a Custom Backend (And What Broke Anyway)</title>
      <dc:creator>Jonathan Mensah</dc:creator>
      <pubDate>Tue, 10 Mar 2026 10:04:03 +0000</pubDate>
      <link>https://dev.to/jonathan_mensah/how-i-migrated-50000-users-from-firebase-to-a-custom-backend-and-what-broke-anyway-3c4b</link>
      <guid>https://dev.to/jonathan_mensah/how-i-migrated-50000-users-from-firebase-to-a-custom-backend-and-what-broke-anyway-3c4b</guid>
      <description>&lt;p&gt;After my Firebase bill hit $300/month, I knew I had to migrate. But I had a problem: 50,000 downloads, ~10,000 active users, and zero tolerance for downtime or forcing password resets.&lt;/p&gt;

&lt;p&gt;Here's how I migrated from Firebase to a custom NestJS + MongoDB stack without breaking everything, and the auth bug that almost ruined it all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenge
&lt;/h2&gt;

&lt;p&gt;I couldn't just flip a switch. I needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero downtime during migration&lt;/li&gt;
&lt;li&gt;No forced password resets (users hate this, I know I do)&lt;/li&gt;
&lt;li&gt;Data consistency between old and new systems&lt;/li&gt;
&lt;li&gt;A way to roll back if things went wrong&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The solution? Run both systems in parallel and migrate gradually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: Building the New Backend (2 Days Planning)
&lt;/h2&gt;

&lt;p&gt;Before touching production, I had to build the entire new stack:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The new setup:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NestJS backend (full API control)&lt;/li&gt;
&lt;li&gt;MongoDB Atlas (free tier)&lt;/li&gt;
&lt;li&gt;$5/month VPS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The first problem: Firestore vs MongoDB data models&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Firestore loves subcollections. MongoDB doesn't work that way.&lt;/p&gt;

&lt;p&gt;In Firestore, I had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;users/{userId}/progress/{examId}/questions/{questionId}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had to flatten this for MongoDB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abc123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;examId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exam456&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;questionProgress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;questionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;questionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deeply nested subcollections had to become embedded documents or separate collections with references. This took careful planning to avoid slow queries later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: The Dual-Write Strategy
&lt;/h2&gt;

&lt;p&gt;Instead of a "big bang" migration, I used a safer approach: &lt;strong&gt;write to both systems simultaneously.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's how the dual-write flow worked:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyuzop4ualx7omzv5tsfp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyuzop4ualx7omzv5tsfp.png" alt="Sequence diagram for dual-write flow" width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The implementation:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Installed Firebase Admin SDK in my NestJS backend&lt;/strong&gt; - This let my new backend talk to Firebase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updated my React Native app&lt;/strong&gt; to call the new NestJS API instead of Firebase SDKs directly&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Set up dual-write proxying:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User makes a request to NestJS&lt;/li&gt;
&lt;li&gt;NestJS writes to MongoDB Atlas (new system)&lt;/li&gt;
&lt;li&gt;NestJS immediately mirrors that write to Firestore (old system)&lt;/li&gt;
&lt;li&gt;Both systems stay in sync&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;For reads:&lt;/strong&gt; I kept reading from Firestore (source of truth) while verifying MongoDB was populating correctly&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The deployment problem:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I needed to push an app update with the new API endpoints, but Play Store review was taking forever. Solution? &lt;strong&gt;OTA (Over-The-Air) update.&lt;/strong&gt; I self-host this, so I pushed the update directly to users without waiting for Google.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: The Authentication Migration (The Tricky Part)
&lt;/h2&gt;

&lt;p&gt;This was the hardest part. I had thousands of users with Firebase Auth accounts, and I couldn't force them all to reset passwords.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lazy migration approach:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of migrating all users at once, I migrated them &lt;strong&gt;as they logged in:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp4kqsdl7phm0n5p9eywa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp4kqsdl7phm0n5p9eywa.png" alt=" " width="800" height="1047"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User tries to log in&lt;/li&gt;
&lt;li&gt;NestJS checks: "Do they exist in MongoDB?"&lt;/li&gt;
&lt;li&gt;If NO:

&lt;ul&gt;
&lt;li&gt;Use Firebase Admin SDK to verify their credentials against Firebase&lt;/li&gt;
&lt;li&gt;If successful, capture their plaintext password during that single login&lt;/li&gt;
&lt;li&gt;Hash it with bcrypt and save to MongoDB&lt;/li&gt;
&lt;li&gt;User is now migrated&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;If YES: User already migrated, authenticate normally with MongoDB&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;For inactive users:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used Firebase's bulk export:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;firebase auth:export users.json &lt;span class="nt"&gt;--project&lt;/span&gt; my-project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then imported their password hashes using Firebase's custom scrypt parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Signer key&lt;/li&gt;
&lt;li&gt;Salt separator
&lt;/li&gt;
&lt;li&gt;Rounds&lt;/li&gt;
&lt;li&gt;Memory cost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This let me pre-migrate users who hadn't logged in yet without forcing resets.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Broke: The Base64 Signer Key Bug
&lt;/h2&gt;

&lt;p&gt;Everything was ready. I deployed. And then &lt;strong&gt;every legacy login failed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Users who hadn't migrated yet couldn't log in. My error logs were filled with authentication failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; Firebase's scrypt &lt;code&gt;signerKey&lt;/code&gt; parameter needs to be &lt;strong&gt;Base64 decoded&lt;/strong&gt; before passing it to the hash verification function.&lt;/p&gt;

&lt;p&gt;I was treating it as a raw string. My backend wasn't decoding it correctly, so Firebase's scrypt hashes never matched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong - treating as raw string&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signerKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FIREBASE_SIGNER_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Right - Base64 decode to buffer&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signerKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FIREBASE_SIGNER_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. That's all it took to break authentication for thousands of users.&lt;/p&gt;

&lt;p&gt;Luckily, my app is still usable without logging in (users can browse questions), so the damage was limited. But it was a stressful few hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Kept on Firebase
&lt;/h2&gt;

&lt;p&gt;I didn't migrate everything. Some Firebase services are still cheap/free and not worth rebuilding:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still using Firebase for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Analytics (still free)&lt;/li&gt;
&lt;li&gt;Cloud Messaging/Push notifications (still free)&lt;/li&gt;
&lt;li&gt;Storage for profile pictures (I'm nowhere near the limit, and images are compressed before upload)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No point migrating these when they're working and costing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deep Nesting Problem
&lt;/h2&gt;

&lt;p&gt;Another issue: Firestore lets you nest subcollections infinitely. MongoDB doesn't work that way.&lt;/p&gt;

&lt;p&gt;I tried flattening deeply nested Firestore structures into single MongoDB documents. &lt;strong&gt;Bad idea.&lt;/strong&gt; Queries became slow because documents got massive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Split deeply nested data into separate collections with references, just like a traditional relational database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Planning:&lt;/strong&gt; 2 days&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution:&lt;/strong&gt; 1 day&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stabilization:&lt;/strong&gt; A few weeks (fixing the auth bug, optimizing queries, monitoring for issues)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Test the scrypt parameters earlier&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I should've written integration tests specifically for Firebase scrypt verification before deploying. That Base64 bug would've been caught immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Start with a small cohort&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of dual-writing for all users at once, I should've tested with 5-10% of traffic first to catch issues before they affected everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Document the data model mapping&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I spent way too much time figuring out how to flatten Firestore's subcollections. A clear document mapping old structure → new structure would've saved hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Set up better monitoring&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I only realized auth was broken when users complained. I should've had alerts for auth failure rate spikes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Migrating from Firebase to a custom backend without downtime is possible, but it's not trivial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dual-write strategy (safe, reversible)&lt;/li&gt;
&lt;li&gt;Lazy password migration (no forced resets)&lt;/li&gt;
&lt;li&gt;Keeping Firebase services that are still cheap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What almost broke everything:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Not properly Base64 decoding Firebase scrypt parameters&lt;/li&gt;
&lt;li&gt;Trying to directly map deeply nested Firestore structures to MongoDB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Was it worth it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Absolutely. I went from $15-20/month (after optimization) to $5/month total. More importantly, I'm not constantly worried about a spike in usage causing a surprise bill.&lt;/p&gt;

&lt;p&gt;If you're considering migrating away from Firebase, take your time, test thoroughly, and run both systems in parallel until you're confident the new one works.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; &lt;a href="https://dev.to/jonathan_mensah/how-i-handle-deployments-and-cicd-for-a-5month-production-app-27ee"&gt;How I handle deployments and CI/CD for a $5/month production app.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written while debugging to: [Așa – Eye Adaba]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>How My Firebase Bill Hit $300 in One Month (And Why I Don't Use Firebase Anymore)</title>
      <dc:creator>Jonathan Mensah</dc:creator>
      <pubDate>Tue, 10 Mar 2026 09:53:59 +0000</pubDate>
      <link>https://dev.to/jonathan_mensah/how-i-migrated-50000-users-from-firebase-to-a-custom-backend-and-what-broke-anyway-1kl1</link>
      <guid>https://dev.to/jonathan_mensah/how-i-migrated-50000-users-from-firebase-to-a-custom-backend-and-what-broke-anyway-1kl1</guid>
      <description>&lt;p&gt;I randomly checked my Firebase dashboard one day and my heart sank.&lt;/p&gt;

&lt;p&gt;$300. In one month. For an app I thought was "basically free to run."&lt;/p&gt;

&lt;p&gt;Turns out "free tier" doesn't mean much when you're making inefficient queries to thousands of active users.&lt;/p&gt;

&lt;p&gt;Here's what went wrong, and why I eventually migrated away from Firebase entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Mistakes That Cost Me $300
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mistake #1: No Caching (The Biggest One)
&lt;/h3&gt;

&lt;p&gt;I was fetching the same data over and over again. Every time a user opened the app, I'd query Firestore for data that hadn't changed in months.&lt;/p&gt;

&lt;p&gt;My app shows past exam questions. These questions don't change. They're literally historical data. But every single user, every single session, was hitting Firestore to fetch them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The math:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~10,000 active users&lt;/li&gt;
&lt;li&gt;Average 3-5 app opens per day per active user&lt;/li&gt;
&lt;li&gt;Each open = full query to fetch questions&lt;/li&gt;
&lt;li&gt;Firestore charges per document read&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's 30,000-50,000 unnecessary Firestore reads per day. Every single day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I should have done:&lt;/strong&gt; Aggressively cache anything that doesn't change. Past exam questions? Cache them locally. Don't hit the database unless absolutely necessary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake #2: No Pagination
&lt;/h3&gt;

&lt;p&gt;I was loading entire question sets at once. Instead of showing 10 questions and loading more as needed, I'd fetch 50-100 questions upfront.&lt;/p&gt;

&lt;p&gt;More documents read = higher bill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I should have done:&lt;/strong&gt; Implement pagination from day one. Load 10-20 items initially, fetch more only when the user scrolls. Firestore charges per read, only read what you need.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake #3: No Billing Alerts
&lt;/h3&gt;

&lt;p&gt;The worst part? I had no idea this was happening until I randomly checked my dashboard.&lt;/p&gt;

&lt;p&gt;Firebase lets you set up billing alerts. I didn't. So I went weeks burning money without knowing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I should have done:&lt;/strong&gt; Set up billing alerts immediately. Get notified when you hit $10, $25, $50. Don't wait until you're at $300 to find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Did to Fix It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fix #1: Aggressive Caching
&lt;/h3&gt;

&lt;p&gt;I implemented local caching for anything that doesn't change. Past questions get cached on the device after the first fetch. Users only hit Firestore when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They're accessing new content&lt;/li&gt;
&lt;li&gt;Cache is outdated (which for past questions, never happens)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This alone cut my Firestore reads by ~80%.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix #2: Pagination
&lt;/h3&gt;

&lt;p&gt;I rewrote my queries to load data in batches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: Load everything&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;questions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;questions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// After: Load 20 at a time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;questions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;questions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users don't need to see 100 questions at once. They need 10-20, then more as they scroll.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix #3: Billing Alerts
&lt;/h3&gt;

&lt;p&gt;I set up alerts in the Firebase console:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;$10 threshold: "Okay, things are running normally"&lt;/li&gt;
&lt;li&gt;$25 threshold: "Check what's spiking"&lt;/li&gt;
&lt;li&gt;$50 threshold: "Something is wrong, investigate now"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This way I'd never wake up to a surprise $300 bill again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bill Dropped... But I Still Migrated Away
&lt;/h2&gt;

&lt;p&gt;After implementing these fixes, my Firebase bill dropped to ~$15-20/month. Much better.&lt;/p&gt;

&lt;p&gt;But here's the thing: &lt;strong&gt;Firebase gets expensive at scale, even when you're being careful.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even with ~10,000 active users , I was constantly worried about costs. One inefficient query, one spike in usage, and my bill could explode again. I didn't want to spend my time optimizing for Firebase's pricing model, I wanted to build features.&lt;/p&gt;

&lt;p&gt;So I migrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  My New Stack (And Why It's Cheaper)
&lt;/h2&gt;

&lt;p&gt;I moved to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NestJS backend&lt;/strong&gt; (full control over my API)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB Atlas&lt;/strong&gt; (free tierno per-read pricing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare R2&lt;/strong&gt; (storage with generous free tier)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$5/month VPS&lt;/strong&gt; (to run the NestJS backend)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total cost now: &lt;strong&gt;$5/month.&lt;/strong&gt; That's it. Not $15-20. Not $300. Just the VPS.&lt;/p&gt;

&lt;p&gt;MongoDB Atlas free tier handles my database. Cloudflare R2 free tier handles my storage. The only thing I actually pay for is the server to run my backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned About Firebase
&lt;/h2&gt;

&lt;p&gt;Firebase is great for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; Prototypes and MVPs&lt;/li&gt;
&lt;li&gt; Apps with small, predictable traffic&lt;/li&gt;
&lt;li&gt; Teams that don't want to manage infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Firebase is NOT great for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; Apps with high read volume&lt;/li&gt;
&lt;li&gt; Apps where data doesn't change often (you're paying to re-fetch the same thing)&lt;/li&gt;
&lt;li&gt; Apps at scale where costs matter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're serving mostly static or slowly-changing data to thousands of users, you'll get killed by per-read pricing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were starting this app from scratch today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set up billing alerts before writing a single line of code&lt;/strong&gt; - Know when costs spike&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache aggressively from day one&lt;/strong&gt; - If data doesn't change, don't fetch it again&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implement pagination immediately&lt;/strong&gt; - Never load more than you need&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Question whether Firebase is the right choice&lt;/strong&gt; - For high-read, low-write apps, traditional backends are often cheaper&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Or honestly? &lt;strong&gt;Start with a $5 VPS and a traditional stack.&lt;/strong&gt; You'll have more control and predictable costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Firebase's "pay as you go" pricing sounds great until you realize you're paying for every single read, even when you're fetching the same data for the hundredth time.&lt;/p&gt;

&lt;p&gt;I went from $0 to $300 in one month because I didn't understand how Firebase pricing works. I fixed it and got down to $15-20/month. Then I migrated entirely and now pay $5/month total.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;Know your pricing model before you scale.&lt;/strong&gt; Firebase is convenient, but convenience has a cost.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; &lt;a href="https://dev.to/jonathan_mensah/how-i-migrated-50000-users-from-firebase-to-a-custom-backend-and-what-broke-anyway-3c4b"&gt;How I migrated 50,000 users from Firebase to a custom backend without downtime (and what broke anyway).&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written while debugging to: [J. Cole – Poor Thang]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>cloud</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why My App Crashed 47 Times in One Week (And the Monitoring Tool That Would've Saved Me)</title>
      <dc:creator>Jonathan Mensah</dc:creator>
      <pubDate>Tue, 10 Mar 2026 09:51:55 +0000</pubDate>
      <link>https://dev.to/jonathan_mensah/why-my-app-crashed-47-times-in-one-week-and-the-monitoring-tool-that-wouldve-saved-me-3d1b</link>
      <guid>https://dev.to/jonathan_mensah/why-my-app-crashed-47-times-in-one-week-and-the-monitoring-tool-that-wouldve-saved-me-3d1b</guid>
      <description>&lt;p&gt;47 crashes in one week. That's what happens when you ship an app to 50,000 users without proper error monitoring.&lt;/p&gt;

&lt;p&gt;My app's rating started tanking. Users were leaving one-star reviews. And I had no idea what was actually causing it.&lt;/p&gt;

&lt;p&gt;The Play Console crash reports told me &lt;em&gt;something&lt;/em&gt; was wrong, SIGABRT errors related to AdMob, but they didn't tell me &lt;em&gt;why&lt;/em&gt; or &lt;em&gt;where&lt;/em&gt; in my code it was happening. The ad unit ID wasn't making it to the app somehow, but I couldn't pinpoint the exact failure point.&lt;/p&gt;

&lt;p&gt;So I did what any panicking developer would do: I started guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tried (That Didn't Work)
&lt;/h2&gt;

&lt;p&gt;I pushed update after update, each one adding more try-catch blocks and "precautionary" error handling around the ad loading logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update 1:&lt;/strong&gt; Wrapped ad initialization in try-catch. Still crashing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update 2:&lt;/strong&gt; Added null checks everywhere. Still crashing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update 3:&lt;/strong&gt; Added more defensive code around the ad unit ID. Still crashing.&lt;/p&gt;

&lt;p&gt;Each update took time to build, publish, and roll out. Each time, I'd wait anxiously to see if the crash rate went down. And each time, the crashes kept coming.&lt;/p&gt;

&lt;p&gt;Meanwhile, my rating dropped from 4.5 stars to 3.8. Users were getting frustrated, and so was I.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Play Console Crash Reports
&lt;/h2&gt;

&lt;p&gt;Don't get me wrong, Google Play Console crash reports are useful. They told me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That crashes were happening (obviously)&lt;/li&gt;
&lt;li&gt;The error type (SIGABRT)&lt;/li&gt;
&lt;li&gt;That it was related to AdMob&lt;/li&gt;
&lt;li&gt;A stack trace that was... mostly useless&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What they &lt;em&gt;didn't&lt;/em&gt; tell me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The exact line of code causing the crash&lt;/li&gt;
&lt;li&gt;The user flow that triggered it&lt;/li&gt;
&lt;li&gt;What data state led to the failure&lt;/li&gt;
&lt;li&gt;Real-time alerts when crashes spiked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was debugging blind, deploying hopeful fixes and waiting days to see if they worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Fixed It: Sentry
&lt;/h2&gt;

&lt;p&gt;After a week of this mess, I did what I should've done from day one: I integrated Sentry.&lt;/p&gt;

&lt;p&gt;One update later, I had my answer within minutes.&lt;/p&gt;

&lt;p&gt;Sentry showed me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The exact line&lt;/strong&gt; where the ad unit ID was undefined&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user flow&lt;/strong&gt; that triggered it (app opening from a cold start in specific scenarios)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The device info&lt;/strong&gt; and OS versions where it happened most&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time alerts&lt;/strong&gt; every time the crash occurred&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Turns out the ad unit ID was being accessed before my config was fully initialized. A simple timing issue I would've never caught without detailed crash context.&lt;/p&gt;

&lt;p&gt;I added a proper initialization check, pushed the fix, and the crashes stopped immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Lesson 1: Play Console crash reports are not enough&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They're a starting point, but they don't give you the context you need to debug effectively. You're essentially flying blind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 2: Adding random try-catch blocks doesn't fix crashes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It just hides symptoms. I wasted a week pushing defensive code that didn't address the root cause because I didn't have visibility into what was actually happening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 3: Real-time error monitoring should be set up BEFORE you ship&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Waiting until your app is crashing in production to add monitoring is like waiting until your house is on fire to install smoke detectors. Sentry takes 15 minutes to integrate, there's no excuse not to have it from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Sentry Specifically?
&lt;/h2&gt;

&lt;p&gt;I'm not sponsored by Sentry (I wish). I'm recommending it because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dead simple to set up&lt;/strong&gt; in React Native (literally 3 steps in their docs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free tier&lt;/strong&gt; that's generous enough for student projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instantly points to the exact error&lt;/strong&gt; with full context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time alerts&lt;/strong&gt; so you know when things break&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source maps support&lt;/strong&gt; so you see readable code, not minified garbage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other tools exist (Firebase Crashlytics, Bugsnag, LogRocket), but Sentry hit the sweet spot of easy setup, useful insights, and actually free for small projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were starting this app from scratch today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Integrate Sentry before the first user&lt;/strong&gt;  -  Not after 50,000 downloads and a crisis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up alerts&lt;/strong&gt;  -  Get notified immediately when crash rates spike&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test error reporting&lt;/strong&gt; - Trigger a test crash to make sure monitoring is working&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never ship blind&lt;/strong&gt; - If I can't see what's breaking in production, I'm not ready to ship&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;You will ship bugs. That's not the problem.&lt;/p&gt;

&lt;p&gt;The problem is shipping bugs &lt;em&gt;and having no idea they exist&lt;/em&gt; until your rating tanks and users are gone.&lt;/p&gt;

&lt;p&gt;47 crashes. One week. One monitoring tool that would've caught it in 5 minutes instead of 7 days.&lt;/p&gt;

&lt;p&gt;Don't make my mistake. Set up error monitoring before your first real user. Your future self (and your app rating) will thank you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; &lt;a href="https://dev.to/jonathan_mensah/how-i-migrated-50000-users-from-firebase-to-a-custom-backend-and-what-broke-anyway-1kl1"&gt;How my Firebase bill hit $300 in one month, and the three pricing mistakes that caused it.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Currently debugging to: [Octavian – Lightning]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>appdevelopment</category>
      <category>typescript</category>
      <category>reactnative</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Building in Public as a CS Student (After a Year in Production)</title>
      <dc:creator>Jonathan Mensah</dc:creator>
      <pubDate>Tue, 10 Mar 2026 09:48:29 +0000</pubDate>
      <link>https://dev.to/jonathan_mensah/building-in-public-as-a-cs-student-after-a-year-in-production-13dl</link>
      <guid>https://dev.to/jonathan_mensah/building-in-public-as-a-cs-student-after-a-year-in-production-13dl</guid>
      <description>&lt;p&gt;My app crashed 47 times in one week. That same month, my Firebase bill hit $300 because I didn't understand read/write pricing.&lt;/p&gt;

&lt;p&gt;By that point, the app had been live for nearly a year and reached 50,000 downloads, which meant every mistake was expensive and public.&lt;/p&gt;

&lt;p&gt;Nothing in my coursework prepared me for production. Shipping to real users forced me to learn cost management, backend scaling, monitoring, and what "it works on my machine" actually means, all through mistakes I couldn't ignore.&lt;/p&gt;

&lt;p&gt;Turns out the best teacher is production breaking at 2am. School never covered that.&lt;/p&gt;

&lt;p&gt;I'm a CS student who ships software to real users and writes about every lesson it teaches me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Here's what I write about:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Why your app crashes at scale, and what testing actually misses&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Firebase and backend mistakes that cost real money&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Decisions that matter after your app gets its first 1,000 users&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What breaks in production that never shows up in tutorials&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Next post:&lt;/strong&gt; &lt;a href="https://dev.to/jonathan_mensah/why-my-app-crashed-47-times-in-one-week-and-the-monitoring-tool-that-wouldve-saved-me-3d1b"&gt;Why my app crashed 47 times in one week, and the one monitoring tool that would've caught it before users did.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Currently debugging to: [Așa – Bibanke]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>softwareengineering</category>
      <category>learning</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
