Published on 9/30/2026
The Update Nobody Installed!
An app with a hard time forcing users to update, and how I self-hosted over-the-air updates for it with hot-updater, Docker Compose, Postgres and an S3-compatible store, including every wrong turn on the way
An App With a Hard Time Forcing Users to Update
This is the story of an app with a hard time forcing users to update.
It's a bare React Native app, Android only, and it doesn't live in a store. It ships as a signed APK that users download from a link. There's already a small "update manager" in it: every 30 seconds it asks the backend for the latest version, compares it with its own using semver, and if it's behind, pops a bottom sheet with a download link.
On paper, that's forced updates. In practice, the sheet shows up, the user taps the link, the browser downloads an APK, Android asks for permission to install from unknown sources, the user gets confused, closes everything and goes back to work on the old version. A week later we fix a bug, bump the version, and the same dance starts again. Some devices were three releases behind.
Most of those fixes were pure JavaScript: a wrong label, a validation rule, a screen that crashed on an edge case. None of them needed a new APK. They needed a way to swap the JS bundle under the user's feet.
That's what over-the-air (OTA) updates are for. The app ships once, and after that the JS bundle gets replaced in the background and applied on the next launch.
First Idea: CodePush
My first thought was CodePush, the default answer for years. But App Center was retired in March 2025, so "use CodePush" now means "run a CodePush server yourself."
The plan I sketched was:
- a self-hosted CodePush-compatible server in Docker Compose
- MinIO for object storage
- the app talking to it through a CodePush client library
Two problems showed up before I wrote a single line.
- The client library. The original
react-native-code-pushis archived and doesn't fit well with the New Architecture. The app is on React Native 0.86, where the New Architecture isn't optional. - Storage. Microsoft's open-sourced server only speaks Azure Blob Storage. MinIO would need a custom adapter.
So I stepped back.
Second Idea: expo-updates
expo-updates is actively maintained, has an open protocol, and has code signing built in. You can run it in a bare app, and there's a reference server you can build on.
When I wrote down the risks, one stood out: pulling Expo modules into a bare app ties you to Expo SDK releases. Each SDK supports specific React Native versions. If the newest SDK didn't support 0.86 yet, the whole plan would be blocked before it started, on a dependency whose schedule I don't control.
Two more lessons came out of that review, and they stuck with me for the rest of the project:
- Release builds block plain HTTP. A local server over
http://needs cleartext exceptions on the device. - Code signing isn't optional. Without it, whoever controls the server, or the network path, can push arbitrary JavaScript to every user.
Does It Even Need S3?
Then a good question came up: does an OTA server need object storage at all?
Honestly, no. An update is just static files: a JS bundle, some assets and a metadata file. A Docker volume mounted into the server is enough for one server and a modest number of devices.
S3 starts to matter when:
- you run more than one server replica
- you want a CDN in front of the assets
- you want durable storage without looking after disks
- you want containers that don't hold data
That changed how I thought about it. Storage should sit behind an interface, so the backend can be a folder today and S3 tomorrow.
Third Idea: hot-updater
- a CLI to build, sign and deploy bundles
- channels, rollout percentages and force updates
- crash detection with automatic rollback
- bundle signing
- a small web console
- a plugin system for storage (S3-compatible) and databases (Postgres, via Drizzle, Kysely or Prisma)
It's younger than the alternatives and has a smaller community. For a bare app that just wants to self-host, it fit best.
Building the Server
The server is a small Node app, and it lives in its own infrastructure repo, separate from the mobile app. Server credentials shouldn't be readable by everyone who can clone the app, and a server that runs 24/7 shouldn't redeploy every time someone merges a UI change.
The stack:
- Hono with
@hot-updater/server, the update API - Postgres for bundle metadata, through the Drizzle adapter
- S3-compatible storage for the bundle files
- Docker Compose tying it together, with one-shot containers that create the bucket and push the database schema before the server starts
hot-updater has no built-in auth. Update checks have to stay public, because devices call them, but the management routes the CLI uses to register bundles need protection. A bearer token middleware covers that:
const app = new Hono()
app.get('/health', (c) => c.json({ ok: true }))
app.use('/hot-updater/api/*', bearerAuth({ token: managementToken }))
app.mount('/hot-updater', hotUpdater.handler)
A few small things tripped me up along the way:
- TypeScript 7 no longer picks up
@types/nodeon its own, soprocesswas suddenly unknown until I added"types": ["node"]to the config. - pnpm 11 refuses packages published in the last day, so it rejected hot-updater versions released the morning I installed them. It also blocks dependency build scripts, like esbuild's, until you allow them.
- The database schema is generated by the hot-updater CLI from the server config itself, so it happens during the Docker build rather than living in the repo.
MinIO Is Gone
docker compose up failed:Image quay.io/minio/minio:latest Error unauthorized: access to the requested resource is not authorized
mc client image: all denied. MinIO had stopped publishing images. I checked how hot-updater's own repo handles it. Their example server builds MinIO from source at a pinned commit, with a comment saying upstream images are no longer public.Building a storage server from source just to run it locally felt wrong, so I switched to RustFS: S3-compatible, maintained, public images. It only supports path-style URLs unless you configure domains, which suits us. hot-updater's S3 plugin already switches to path-style when you give it a custom endpoint. I only knew that for sure after reading the plugin's source; the docs don't mention MinIO or custom endpoints in that detail.
The Gotcha That Would Have Cost Me a Day
This one's worth its own section.
The server never streams bundles itself. It hands the device a presigned URL, a time-limited link signed with the storage credentials, and the device downloads straight from storage.
http://rustfs:9000 (its Docker service name), every URL it signs points at rustfs:9000, which a phone can't resolve. You also can't rewrite the host afterwards, because that breaks the signature.So the storage endpoint the server uses has to be an address the phone can reach: the machine's LAN IP locally, or a public domain later. The container reaches the LAN IP just fine. I verified it by uploading a test file through the LAN endpoint, generating a presigned URL, and fetching it from outside Docker.
Everything had passed my curl checks from the laptop, so I would only have found this on the first real device test, most likely after a long afternoon of blaming the app.
Wiring the App
The native side of a bare app needs one change: in release builds, load the JS bundle from hot-updater instead of from the APK.
getDefaultReactHost setup. But hot-updater ships an Expo config plugin that makes exactly these edits automatically, so I read its source and applied the same change by hand:getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages,
jsBundleFilePath = if (BuildConfig.DEBUG) {
null
} else {
HotUpdater.getJSBundleFile(applicationContext)
},
)
On the JavaScript side, the root component gets wrapped:
export default HotUpdater.wrap({
baseURL: HOT_UPDATER_URL,
updateStrategy: 'fingerprint',
onError: (error) => logError('OTA update failed', describeError(error)),
onNotifyAppReady: ({ status, crashedBundleId }) => {
if (status === 'RECOVERED') {
logError('OTA bundle rolled back', crashedBundleId)
}
},
})(Sentry.wrap(App))
A few decisions here mattered more than the code.
The fingerprint strategy. Every build gets a hash of its native code, and an update is only offered to APKs with the same hash. If someone adds a native library and forgets to ship a new APK, old devices simply never see JavaScript that expects native code they don't have. The alternative, matching by app version, relies on people remembering to bump versions.
react-native-config, which reads .env.stage and .env.prod. The obvious place for the deploy token and storage keys was right there. But react-native-config makes every value in those files readable by the app, which means they end up inside the APK. So the CLI secrets went into their own .env.hotupdater, which only the CLI reads and nothing bundles.stage and prod flavors built from the same manifest. hot-updater's channel set command writes one channel into it, which would mean every build type gets the same channel. Instead, the manifest reads a placeholder and each flavor sets its own:productFlavors {
stage {
manifestPlaceholders = [hotUpdaterChannel: "stage"]
}
prod {
manifestPlaceholders = [hotUpdaterChannel: "production"]
}
}
AndroidManifest.xml and Info.plist: a 70-line diff and a 170-line diff. Instead of trusting it, I parsed the old and new files and compared them entry by entry. Nothing was lost. The only changes were the new hot-updater keys and flattened formatting.One more detail: the PEM key sits inside an XML attribute, and XML turns line breaks in attributes into spaces. I checked the Android verifier. It strips whitespace before decoding, so the key survives.
The test suite noticed too. Jest crashed because the native module doesn't exist in Node. One mock fixed it, and all 277 tests passed again.
First Deploy
◆ ✅ Build Complete (bare)
◆ ✅ Bundle Signing Complete
◆ ✅ Upload Complete (s3Storage) • 100%
◆ ✅ Update Complete (standalone-repository)
└ 🚀 Deployment Successful (01a0eec0-49e7-7f3c-859d-dab7642ad7ee)
status: "UPDATE", a signed hash and presigned URLs pointing at the LAN address. The 4.7 MB bundle downloaded fine.production channel returned null, and a wrong fingerprint returned null. Updates only go where they belong.Here's the whole flow:
And what the device does:
"How Do I Know the New Version Is Running?"
That was the next question, and it's the right one. An OTA you can't observe is an OTA you can't trust.
release or dist right. It also handles Hermes' extra source-map step. The Sentry URL, org and project come from the same committed properties file the Gradle upload uses, so there's one source of truth.Which bundle is running. Every Sentry event now carries two tags:
Sentry.setTags({
'ota.bundle_id': HotUpdater.getBundleId(),
'ota.channel': HotUpdater.getChannel(),
})
ota.bundle_id:<id> shows exactly which devices picked up a given deploy. I kept release and dist as the APK's own version on purpose. Otherwise one APK's issues would be split across a dozen OTA "releases."HOT_UPDATER_SKIP_SENTRY=1 switch, so throwaway local test deploys don't pollute the real Sentry project.Android Only, Except Where It Isn't
The app is Android only, so I reverted the iOS changes to keep the diff honest. The next deploy failed:
Signing is enabled but HOT_UPDATER_PUBLIC_KEY is missing from Info.plist
-p android, and there's no setting to skip it. The fix was a compromise: the hot-updater plist entries stay, but AppDelegate.swift is untouched, so iOS still loads its built-in bundle and never an OTA. It's metadata only, and the tool is happy."Does It Support Our Other Apps?"
There was a second React Native app on the same stack, so this came up quickly. I looked at the database schema before answering, and it has no app column. Bundles are identified by platform, channel and fingerprint.
Update checks would mostly work, since different apps have different native code and therefore different fingerprints. Everything else would break:
- the bundle list and console would mix both apps
- "roll back the latest production bundle" could roll back the other app
- one token could deploy either app
APPS list, and each app gets:/<app>/hot-updater |
| Database | hot_updater_<app> |
| Storage path | apps/<app>/ in one bucket |
| Deploy token | HOT_UPDATER_AUTH_TOKEN_<APP> |for (const config of loadApps()) {
const hotUpdater = createAppHotUpdater(config)
app.use(`${config.basePath}/api/*`, bearerAuth({ token: config.authToken }))
app.mount(config.basePath, hotUpdater.handler)
}
401, one app's fingerprint on the other app's path returns null, and each app's files land in its own folder..env and a restart.One Last Upgrade: Postgres 18
The last request was Postgres 18 instead of 16. It isn't a one-line change, for two reasons:
- A new major version can't read the old version's data files. You dump and restore, or start fresh.
- The Postgres 18 image stores data in a versioned subdirectory and expects the volume mounted one level up, at
/var/lib/postgresql, not/var/lib/postgresql/data.
The old volume only held test bundles, so the new version got a fresh, newly named volume and the old one was left alone. The migrate step recreated the database, a fresh deploy repopulated it, and the data survived a restart.
"Can Someone Tell We're Doing This?"
com.hotupdater.* entries in the manifest, the library's classes, and the server URL inside the JS bundle. Renaming classes won't hide network calls or manifest entries, so it isn't worth trying.What matters is what isn't in the APK: the private signing key, the deploy token and the storage credentials. The public key, the channel and the fingerprint are all safe to expose. The worst someone can do with them is download a JavaScript bundle much like the one already inside the APK.
The core of the security model is this: even with full control of the server and the bucket, nobody can push code to users without the private key, because the app rejects anything that doesn't verify against the public key it shipped with. So the things to protect are the key, then the deploy token, then the transport (HTTPS in production).
Where I Left It
What works today, verified locally:
- the server stack (Hono, Postgres 18, RustFS) with per-app isolation
- signed deploys from the app repo in about a minute
- update checks that respect channel and fingerprint
- presigned downloads that a phone can actually reach
- source maps in Sentry and a bundle-id tag on every event
- READMEs in both repos with diagrams, setup, day-to-day commands and troubleshooting
What's still open, on purpose:
- A real device test. Build a stage release APK, change a label, deploy, reopen the app twice, see the new label. Then test rollback and crash recovery. Every step is written down and ready, and it's the only verification that really counts.
- HTTPS. The release build allows cleartext traffic, which is fine on a LAN and wrong on the internet. Production needs TLS and a real domain.
- Scoped storage credentials. The server and CLI share the storage root keys. That's fine locally, but not beyond that.
- Pinned images.
rustfs:latestshouldn't survive into production. - Backups. Both the Postgres volume and the bucket, since you need both to restore a bundle.
The funny part is that the "update manager" sheet is still in the app, and it should stay. OTA can't ship native changes, so the next time a native library changes, the APK link will be needed again. But for every other fix, the users who never installed an update won't have to. They'll just open the app.