Store review is a poor fit for a one-line fix. Finly is an Expo app, and its JavaScript and asset changes reach phones through over-the-air updates served from a small server we host. Native changes still go through the stores. Most of the craft is in knowing which is which.
How an update reaches a phone
The app is configured to check for an update on every launch and to start immediately with whatever it has cached, instead of waiting on the network. If a newer bundle exists, it downloads in the background and the user sees a prompt to restart now or later. Choosing later does not cancel anything: the update is already downloaded and applies on the next cold start. There is also a manual check in settings.
The server is an open-source implementation of Expo's update protocol, run as a container on Railway with Postgres and a persistent volume for the bundles. A startup script turns the platform's single database URL into the separate variables the image expects and creates its two tables if they do not exist. The health check points at the root path because the image has no health route. The process is capped on memory.
Publishing is a script with checks
Publishing is one script. It exports the bundle for both platforms, then refuses to continue if the export does not contain the metadata file the server depends on. It generates a second file from the app's config, because the server reads that to build its manifest. It zips the result and uploads it with the runtime version and the git commit and message, after showing us those and asking for confirmation.
Those checks exist because the failures are quiet. An update that uploads but cannot be served looks fine until nobody receives it. Both platforms ship together; the script cannot publish an iOS-only fix.
The runtime version is the safety catch
An update only reaches installs whose runtime version matches. Ours is a fixed string that has not changed since the first release, and we do not bump it on every release. We bump it only when a native change could break a bundle that is already on someone's phone: an Expo SDK upgrade, a native module with a changed interface.
The cost is real. After a bump, installs on the old runtime stop receiving updates and need the store build instead. That is the intended behaviour, and it is better than shipping JavaScript to a binary that cannot run it.
What still needs the stores
Anything that touches native code goes through a full build and review: a new native dependency, a config plugin change, permission strings, the icon or splash screen, entitlements, an Expo SDK upgrade. The production profile builds an iOS Release and an Android app bundle, signed from local credentials, with store versions bumped in lockstep and build numbers independently.
Builds run locally with the EAS command line instead of on EAS's paid cloud builds, and a separate script submits the artifact. Releasing it to users is still a step we do by hand in the store console.
What this setup does not give you
There is no rollback command. If we ship a bad update, we fix it through the server's admin dashboard or its database, or publish a corrected update on top. Over-the-air behaviour cannot be tested in a development build, where updates are disabled, so verifying a change means installing a release or preview build and relaunching it after publishing.
Self-hosting also means we own the server, the database and the volume. We would not call it simpler than a managed service. It is a trade: more to look after, in exchange for not having another vendor in the path of every release.