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.
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:
| Header | Meaning |
|---|---|
expo-protocol-version | Protocol revision the client speaks. |
expo-runtime-version | The native contract identity. Only bundles built for this value may be served. |
expo-platform | ios or android. |
expo-current-update-id | The 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:
noUpdateAvailable— the client is current.rollBackToEmbedded— discard the downloaded update and go back to the bundle compiled into the binary. This is the escape hatch when a published update turns out to be broken, and it is the reason rollback works without an app store release.
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 signing private key is the most sensitive thing in the system. Anything that can read it can ship code to every install.
- The certificate must be embedded in the app at build time, which means rotating it requires an app store release. Plan the expiry accordingly.
- A server that generates a throwaway self-signed certificate on first boot is fine for local development and unacceptable in production — the trust anchor changes whenever the container restarts.
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:
- Code signing uses a persistent certificate whose expiry you have recorded, with the private key held somewhere your application can read and your logs cannot.
- The storage bucket is private, verified by trying to fetch an artifact URL without credentials.
- If a CDN is in front, unsigned, tampered, expired and origin-bypass requests have each been tried against the real edge and rejected.
- TLS renewal is automated and alerts on failure.
- You have restored the database from a backup once, on purpose.
- A staged rollout can be paused, and the rollback path has been exercised on a real device rather than assumed.
- You know what the client does when the server is unreachable — the correct behaviour is that the app keeps running on its current bundle.
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.