Engineering

Self-hosting Expo OTA updates on Cloudflare Workers and R2

We ship over-the-air updates to our Expo apps from our own Worker and R2 bucket, with code signing and gradual rollouts, and four requirements that failed silently.

Over-the-air updates are one of the best things about building an app with Expo. You fix a typo, a layout bug or a wrong calculation in JavaScript, publish, and installs pick it up on their next launch. No store review, no waiting a day for Apple. For a JavaScript-only fix, it reaches users in about thirty seconds.

Expo's hosted service for this, EAS Update, is the easy way in, and for plenty of teams it's the right one. We run our own instead: a small Cloudflare Worker and an R2 bucket per app. Three of our apps use it, including Perron and Lorenly, and our game Scrapheap has its own variant of the same idea.

Building it took longer than we expected, mostly because of four requirements the native client enforces without ever telling you. Here's how the whole thing works, what it costs, and what we'd want to know if we were starting again.

Why we didn't just use EAS Update

EAS Update is good software. Its free plan includes 1,000 monthly active users with no overage, so once you're past that you upgrade. The paid plans include more users and then charge per active user beyond the allowance. Expo's pricing page has the current numbers.

That's a perfectly fair model. It's just a poor fit for a free app that might grow faster than it earns. Perron is free and has no ads, so a cost that rises with every user is a cost we'd eventually have to cover from somewhere else.

On Cloudflare, the cost barely moves with users. An update check is one small Worker request. A release is a few megabytes in R2, and R2 doesn't charge for egress. Each install downloads a bundle once per release, and asset files are cached at Cloudflare's edge for a year. In practice, our update servers are a rounding error on the bill.

There were other reasons too, and honestly they mattered as much as the money. We wanted our own rollout rules, a tester channel our own phones could join from a debug screen, and a rollback we could trigger with one command. With our own server those are a few dozen lines each.

What OTA can and can't change

This line is set by the app store rules, not by Expo, and it applies to every OTA system. You can ship interpreted JavaScript and assets: React components, copy, styles, images, fonts, even whole new screens, as long as they only use native modules that are already in the binary. You can't add a native library, a new permission or an entitlement. Those need a store build.

The runtime version is what enforces it. Every binary embeds one, and the server only offers an update to binaries with the same runtime version. When native code changes, the runtime version changes, and older binaries simply stop being offered new JavaScript. They keep working on what they have.

The shape of it

app (expo-updates) ──GET /api/manifest──► OTA Worker ──► R2: channels/<channel>/<runtime>.json
       ▲  embeds a certificate                │              (pointer to a release)
       │  verifies expo-signature             ├──► signs the manifest
       └──── GET /assets?path=… ◄─────────────┘    streams bundle + assets from R2

The OTA service is its own Worker, separate from the app's API. That was deliberate. An update check has a completely different traffic shape from Perron's chat: right after a popular release, every install asks at once. Keeping the two apart means an update storm can never compete with a live chat room. The OTA Worker owns one R2 bucket and one signing key, and it never touches the database, the Durable Objects or any user data.

The same code is deployed three times, once per app, each with its own bucket, its own hostname and its own signing key. The hostname matters more than you'd think; it ends up as the app's scope key, which comes up again below.

How releases are laid out in R2

releases/<releaseId>/ios.json            # the exact manifest bytes we sign, per platform
releases/<releaseId>/android.json
releases/<releaseId>/release.json        # audit record: time, git sha, runtime versions
releases/<releaseId>/_expo/static/js/…   # the JS bundle
releases/<releaseId>/assets/<hash>       # each asset, content-addressed
channels/<channel>/<runtimeVersion>.json # the pointer: which release a channel serves

A release ID is <ISO timestamp>_<git short sha>, so IDs sort by time and tell you which commit they came from. Releases are never edited after upload. Rolling out, rolling forward and rolling back only ever rewrite the small pointer file under channels/.

That one rule simplifies everything. A release in R2 is a frozen artefact you can always go back to. Asset responses can carry cache-control: public, max-age=31536000, immutable, because a content-addressed file can't change. And the "current state" of a channel is one small JSON file you can read with curl.

The manifests are built at publish time and stored as the exact bytes we'll sign. The Worker doesn't assemble a manifest per request. It reads the pointer, picks the release, loads the stored manifest for the platform, signs it and sends it. Fewer moving parts at request time means fewer ways to send a slightly wrong manifest to a million phones.

The assets route only serves paths under releases/ and rejects anything with .. in it. The bucket also holds the channel pointers, and there's no reason to let anyone fetch arbitrary keys from it through the Worker.

Speaking the protocol

The expo-updates client asks for /api/manifest with headers that describe itself: the platform, the runtime version, the channel, the ID of the update it's currently running, and any extra parameters the app has set. The answer is a multipart/mixed response with one part. Either it's a manifest, or it's a directive such as "no update available" or "roll back to the embedded bundle".

const boundary = `ota_${crypto.randomUUID().replace(/-/g, "")}`;
return new Response(body, {
  headers: {
    "content-type": `multipart/mixed; boundary=${boundary}`,
    "expo-protocol-version": "1",
    "expo-sfv-version": "0",
    "cache-control": "private, max-age=0, must-revalidate",
  },
});

The manifest response must never be cached by anything in between, which is what that cache-control line is for. The assets it points to should be cached for as long as possible. Getting those two backwards is a fun way to spend an afternoon.

Code signing isn't optional

Think about what this endpoint does. The app runs whatever JavaScript it returns, with all the permissions the app has. If someone could impersonate the endpoint, or tamper with a response, they'd be running code on every user's phone.

So every manifest is signed, and the app refuses anything that doesn't verify. The app embeds a self-signed certificate plus codeSigningMetadata with keyid: "main" and alg: "rsa-v1_5-sha256". The Worker holds the private key as a secret and signs the exact body bytes with WebCrypto:

const key = await crypto.subtle.importKey(
  "pkcs8", pemToDer(env.CODE_SIGNING_PRIVATE_KEY),
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"],
);
const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(body));
// on the manifest part:
// expo-signature: sig="<base64>", keyid="main", alg="rsa-v1_5-sha256"

npx expo-updates codesigning:generate gives you a PKCS#1 key, and WebCrypto wants PKCS#8, so convert it once. You want a file that starts with BEGIN PRIVATE KEY, not BEGIN RSA PRIVATE KEY. Importing the key is the slowest step of the whole request, so we cache the imported key per isolate.

It has to be asymmetric. We've seen people suggest an HMAC, which looks simpler, but the app would need the shared secret to check it, and anything shipped inside an app binary can be extracted. With a public key in the app and the private key only in the Worker, there's nothing in the binary worth stealing.

The downside: rotating the key needs a store build, because the certificate lives inside the binary. Keep the private key somewhere safe and boring.

For a while, early on, we had signing turned off while debugging. We turned it back on in July 2026. If you take one thing from this post, don't ship an OTA endpoint without it.

Four requirements that fail silently

This is the part that cost us days. Getting the native client to accept a manifest we'd built ourselves took four separate fixes. None of the failures produced an error we could see. Each one also hid the next, so fixing one just moved us to the next silent failure.

The runtime version has to be computed the same way on both sides. We started with Expo's fingerprint policy, which hashes the native project. The value the build embedded was different from the value our publish script calculated, so every update was filed under a runtime version no install ever asked for. The client asked, the server said "nothing for you", and everyone was happy except us. We moved to the appVersion policy, and later started pinning an explicit version.

extra.scopeKey has to be in the manifest, and it has to equal the app's scope key, which is the normalised origin of the update URL. Without it, the client downloads the update, fails to store it, throws it away, and downloads it again on the next launch. Forever. If you see the same installs downloading the same update again and again, look here first.

extra.expoClient has to be in the manifest too. It's the full public Expo config, the output of expo config --json --type public. It's easy to miss, and without it Constants.expoConfig is null when the app launches from an update. Any module that reads app config then crashes at launch, and expo-updates rolls the app back to the embedded bundle. From the outside it looks like the update simply didn't apply.

The key ID, the certificate and the secret all have to match. A mismatched keyid makes the app download the update, check the signature, and quietly reject it.

Our publish script now sets the first three automatically. The fourth is a matter of keeping the key, the certificate and the app config together.

Rollouts, testers and rollback

Gradual rollout works on a stable random ID. Each install generates one and sends it as an Expo Updates extra parameter. The Worker hashes rolloutId + ":" + releaseId with SHA-256, takes the first four bytes as a number, and reduces it to a bucket from 0 to 99. An install is in the rollout when its bucket is below the percentage. If the ID is somehow missing, the connecting IP is used instead, which is rougher but still stable enough.

Putting the release ID in the hash reshuffles the buckets for every release, so the same unlucky install isn't always the last to get fixes. Installs outside the rollout keep getting the previous release, never "no update", so nobody accidentally drops back to the embedded bundle while a rollout is at 10%.

The tester channel is for our own phones. A debug screen lets a device opt in, and it then sends a tester flag. While the testers pointer has a newer release than the device's own channel, it gets the tester release. As soon as production moves past it, testers just follow production again, with nothing to switch back. A rollback always wins over the tester channel, so a broken tester build can't outlive a rollback.

Rollback is one command. It writes a rollBackToEmbedded directive with the current time into the channel pointer. Every install running an update published before that moment goes back to the bundle inside its binary on the next launch. It's faster than publishing a fix, and it's what you want at the exact moment a bad update is crashing on launch.

Publishing is a manual GitHub Actions workflow with two inputs, the channel and the rollout percentage. It uses an R2-scoped token, separate from the Workers-only token that deploys the Worker. That split is deliberate: the token that can change what code runs on phones isn't the one that changes the server.

Scrapheap's variant: D1 as the control plane

Our game Scrapheap does OTA differently, because it isn't an Expo app. It's a Godot game, so what it downloads is game content, not a JavaScript bundle. But the lessons carried over, and a few new ones turned up.

R2 is still the content store, and it wins any argument. The pointer in R2 is the truth about what's live. What Scrapheap added is a D1 database next to it, as a control plane: a table of releases, the current state of each channel, an append-only audit log, and daily counts of what the server decided for each check.

The reason for SQL is that every question a dashboard asks is a GROUP BY. How many installs checked today? How many were held back by a rollout? Which release is each channel serving, and since when? Against R2 alone each of those is a prefix listing followed by a pile of object reads. Against D1, each is one query.

The write order is strict. Promoting a release writes the R2 pointer first, then records it in D1. If the D1 write fails, the promotion has still happened and the dashboard is briefly out of date, which is the safe way round. The Worker even runs with no database at all: the admin API answers 503, telemetry is skipped, and content delivery carries on. Delivery must never depend on the dashboard existing.

The daily decision counts exist because of one failure we'd already lived through: a rollout that sat at 10% for a week without anybody noticing. The client can't tell you it's being held back, since it gets the same "nothing new" answer as when nothing's published. Only the server knows why it said no, so the server writes it down: no pointer, rolled back, withheld by rollout, served.

Gotchas worth knowing in advance

Your first store build may not be OTA-capable at all. A binary built before expo-updates was added never checks for updates. OTA starts working from the next store release, and not one moment earlier.

Pin the runtime version when a store release doesn't change native code. Otherwise the new app version gets a new runtime version, and every install still on the previous version is stranded on its last update until it upgrades from the store. We pinned Perron's runtime so two consecutive binaries could share updates.

Test the signature yourself before trusting the app to. Fetch a manifest with curl, split out the part, and run openssl dgst -sha256 -verify against the public key from the certificate. That's exactly what the app does, and it's much easier to debug from a terminal.

And keep the update server boring. It's the one piece of infrastructure that can push code to every phone you've ever shipped to. Ours does one thing, holds one key, and touches nothing else.

More from the same stack: range requests and video on Workers, building Expo Android apps locally, and choosing between D1, KV, Durable Objects and R2.

Start a project

Have something you want built?

Tell us about it. We reply to every message with an honest take on whether we are the right fit.

Start a project