RollPilot

Self-hosting Expo Updates: what the protocol actually requires

Expo Updates Protocol v1 is an open contract, which is why several independent servers implement it and the official client talks to all of them. This page describes what that contract is, and — more usefully — which parts of self-hosting are not the server at all.

Vendor-neutral by intent. This is written by the team building RollPilot, which is one of the options and is not publicly available yet. Everything below applies equally to Xavia OTA, Expo Open OTA, a fork of the Expo example server, or something you write yourself. Where a detail is an implementation choice rather than part of the protocol, it says so.

What the protocol is

An update check is one HTTP request. The client asks whether a newer JavaScript bundle exists for its exact native binary, and the server answers with a manifest, or with a directive saying there is nothing to do.

The critical constraint is runtime version. A JS bundle is only safe to load into a native binary whose native modules match what the bundle was compiled against. The runtime version is the identity of that native contract. Shipping a bundle to a mismatched runtime version is the classic way to hand your users a white screen, so the protocol makes it a first-class request parameter rather than something the server infers.

The request

The client sends its identity as headers:

HeaderMeaning
expo-protocol-versionProtocol revision the client speaks.
expo-runtime-versionThe native contract identity. Only bundles built for this value may be served.
expo-platformios or android.
expo-current-update-idThe update currently loaded, so the server can answer "nothing new".

The response

Either a multipart manifest — the update's id, its launch asset, its other assets, and metadata — or a directive. Two directives matter in practice:

Assets in the manifest are referenced by URL. Those URLs should be content-addressable — keyed by the hash of the file — so an asset that has not changed between releases is neither re-uploaded nor re-downloaded, and a given URL can be cached indefinitely because its content can never change.

Code signing

Without signing, anyone who can answer at your update URL can execute code inside your app. TLS alone does not solve this: it protects the channel, not the payload, and it does not help if the server itself is compromised or misconfigured.

The protocol carries a signature in an expo-signature response header, with the client configured to trust a certificate you control. The client verifies before loading. Practical consequences:

The part that is not the server

Running the server process is the easy half, and it is the half every README covers. What actually consumes the time:

Object storage

Bundles and assets have to live somewhere durable. Local disk works until you have more than one instance, or until the disk dies. Moving to S3-compatible storage (S3, R2, COS, MinIO) means the bucket must stay private — a public-read bucket makes every release publicly downloadable, including any release you later pull for being broken.

Private storage means the manifest cannot simply contain a bucket URL. You need either signed, short-lived URLs, or a gateway route on the application that authorises and streams the bytes. Both work; they have different cost and latency profiles, since the gateway approach puts artifact traffic through your application instances.

CDN

Serving binaries directly from application instances is workable at small scale and expensive at large scale. Putting a CDN in front introduces the problem most people hit late: the CDN must not become a public mirror of your private bucket. That requires origin authentication — the CDN authenticating to storage, and ideally edge-level URL signing so an unsigned or expired link is rejected at the edge rather than at your origin.

Every CDN spells this differently, and a signature implementation that passes local tests proves nothing about the edge configuration. This is worth verifying against the real CDN, with explicit negative cases: an unsigned URL, a tampered signature, an expired link, and a direct-to-origin request that bypasses the CDN entirely.

TLS and the database

Certificate renewal has to be automatic and monitored, because an expired certificate means every client in the field silently stops receiving updates — including the update you would use to fix it.

The metadata store holds release history and rollout state. SQLite is genuinely sufficient for a single instance and keeps the deployment to one binary and one file. More than one instance means a managed database, and it means having actually restored from a backup at least once rather than merely configuring one.

Multiple apps

Most open-source implementations model one app per deployment. If you ship three apps, or white-label the same codebase for several customers, that becomes three deployments, three databases and three CDN configurations. Worth checking before you commit, because retrofitting multi-tenancy is considerably harder than choosing it up front.

A realistic checklist

Before pointing production traffic at a self-hosted update server:

Deciding whether to self-host

Self-hosting is the right answer when data residency, network isolation or vendor independence are real requirements, and when someone will own the operational work described above. It is the wrong answer when it is chosen only to avoid a subscription — the infrastructure is not the expensive part, the attention is.

The honest framing: the software is largely a solved problem, with several competent open-source implementations. What you are actually deciding is who carries the pager.