Last month I moved maru’s over-the-air updates from my own expo-updates server to Codemagic Patch. Everything in that post ran against Patch’s local evaluation stack on my laptop. The section on production was a plan, with a note to myself to replace it once it had actually happened.
This is that replacement. maru’s Patch server has been running on Fly.io since 5 October, on one of the smallest machines Fly sells, with its files on Tigris, and every update maru’s users get comes through it. I set that server up by hand. It took an afternoon, and turned up a few things about Tigris and Fly that the docs didn’t cover.
You don’t have to do any of that now. Patch 0.8 (28 October) added a Fly.io install to the CLI: one command on your laptop, three questions, two sign-ins in the browser, and it builds the same setup I did, with the lessons already in it. So this post starts there. After it comes what the wizard builds and why, which is what I learned doing it by hand. You don’t need that part to get running, but it’s worth reading once before your first store build.
The second half is about using OTA updates during development, to get new code onto a tester’s phone without a native build. That turned out to be the most useful thing I did with Patch this month. The last part covers maru’s first two weeks in production on Patch.
What has to run
Patch’s server is one Node process. In MODE=all it serves the API, runs the background worker that turns an uploaded bundle into published files, and serves the web dashboard, all from one container. It needs three things around it:
- Postgres, for apps, releases, users and the job queue.
- S3-compatible object storage, for the bundles and the manifests that describe them.
- A sign-in provider: GitHub, GitLab or Bitbucket. The server won’t start without one.
The design choice that makes it cheap to host is the one I mentioned last time: phones never ask the server whether there’s an update. When a release is published, the worker writes plain JSON manifests into storage, and the app reads those directly. An update check is a couple of static GETs against a bucket. The server only hears from phones when they report how an install went.
So the server can be small, and it doesn’t sit between a phone and its update. If the machine goes down, every phone still gets the last published release. For the scaling side of that design, see Designing scalable OTA update architecture for React Native.
On Fly, that comes to one app for Patch, a database, and two buckets. Phones download manifests and bundles straight from a public Tigris bucket, and send their install reports to the Patch server. The CLI and the dashboard talk to the same server. The server writes to Postgres, to the public bucket, and to a second, private bucket where uploads wait before they’re published.
One command
The wizard is part of Patch’s CLI from 0.8.0 and runs on your own machine. It drives Fly’s and Tigris’s command-line tools from there, so there’s no server of your own to set up and no domain to buy:
$ npm install -g @codemagic/patch-cli
$ cmpatch selfhost install --fly
I ran it for this post on 29 October, in my personal Fly organisation, under the name Maru Demo. Everything below is from that run.
It starts by sorting out the accounts. If flyctl or the Tigris CLI is missing, it offers to install them. It asks whether you have a Fly account, and signs you in or up. Then it checks that the Fly organisation has a card, and if it doesn’t, it opens Fly’s billing page and waits while you add one. Adding a card ends Fly’s free trial and billing starts, and the wizard tells you so before you do it. The bill is a few dollars a month; the numbers are at the end.
Then it asks three questions:
- What’s the company called? Everything is named from it.
Maru Demogives a Fly app and public bucket calledmaru-demo-patch, a private bucket calledmaru-demo-patch-internal, and, if you want one, a database app calledmaru-demo-patch-db. The app’s name is also its address,https://maru-demo-patch.fly.dev. Bucket names are global on Tigris, so if someone else already has yours, it asks for another name. - Which region? Seven common ones are listed, and Other takes any Fly region code. Phones download from Tigris, not from here, so choose one near you and near your database.
- Do you already have a Postgres database? Either it creates one on Fly, or you paste the URL of one you already run.
After that it works through the rest, printing each step as it goes:
- It creates the two buckets.
- It sends you to Tigris in the browser, to make one access key for both buckets. The Tigris CLI shows a short code; you confirm it in the browser, and on Tigris’s login page you choose Fly.io, under “Signed up with Fly?”, so the storage is billed with the rest of your Fly organisation. If your browser was already signed in to Tigris another way, the wizard notices and tells you how to switch.
- It checks the key before using it: a test file goes into each bucket, comes back with the key, and is fetched again with no credentials at all. The public bucket has to serve it and the private one has to refuse. Then both are deleted.
- It walks you through creating the sign-in app on GitHub, GitLab or Bitbucket, with your new address already filled in, and asks for the administrator’s email.
- It creates the Postgres machine, if you asked for one.
- It builds the newest Patch server release on Fly’s builder, deploys it, and waits until it answers.
The whole run in a terminal, with the database and server build sped up ten times.
Everything after the last question took 7 minutes 15 seconds: 1 minute 22 seconds for the Postgres machine, 25 seconds to fetch the server release, and 5 minutes 25 seconds for Fly to build and deploy it. The buckets, the key and its check took six seconds between them. From the first question to the end, including the two stops in the browser, it was about a quarter of an hour.
One tip: sign in to fly.io in your browser before you start. The Tigris code expires after about five minutes, and my first attempt ran out while I was still signing in to Fly. The wizard stopped cleanly, and when I ran it again it found the two buckets it had already made and offered to reuse them.
Patch is running.
Dashboard: https://maru-demo-patch.fly.dev
Download URL: https://maru-demo-patch.t3.tigrisfiles.io
Both addresses are built into the app binary. Changing either one later means a new store build.
Sign in with GitHub to create the admin account, then connect this machine:
cmpatch login --server-url https://maru-demo-patch.fly.dev
cmpatch init --server-url https://maru-demo-patch.fly.dev
The database disk is one volume, and Fly's daily snapshot is the only backup this command arranged.
Sign in to the dashboard once, so the administrator account exists. Then cmpatch login connects your machine, and cmpatch init, run in your app’s folder, creates an app per platform on the new server and wires the SDK into your project. That’s the whole install. For maru I did those last steps by hand; they’re further down.
The new server’s status page, minutes after the install. Upgrading is “an operator step: run cmpatch selfhost upgrade from the machine you installed it from”.
The wizard also records the install on the machine you ran it from, so the upgrade, backup and restore commands later know where it is without being told. If your CLI already pointed at another server (mine was still pointing at the local evaluation stack), it asks whether to switch.
What the wizard builds, and why
I set maru’s server up by hand at the start of October, before the wizard existed. It’s the same shape as what the wizard builds, with two differences: its database is on the Postgres cluster maru already pays for, and its download address uses an older Tigris hostname. The rest of this section is what I worked out doing it, which the wizard now does for you. You don’t need any of it to run the wizard. It’s here so you know what’s on your Fly account, and because two of these values end up in every binary you ship.
Two buckets, because Tigris has no bucket policies
Patch’s bundled install runs MinIO, and keeps everything in a single bucket. Most of it has to be public, because that’s where phones download from. One prefix has to stay private: _internal/, where uploaded bundles wait before the worker publishes them. The bundled stack does that with a bucket policy, allowing anonymous reads of everything and denying them under _internal/*:
{
"Statement": [
{ "Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::codemagic-patch/*"] },
{ "Effect": "Deny", "Principal": "*", "Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::codemagic-patch/_internal/*"] }
]
}
When I set up maru, I created a Tigris bucket and gave it that policy:
$ aws s3api put-bucket-policy --bucket maru-patch-artifacts \
--policy file://policy.json --endpoint-url https://fly.storage.tigris.dev
An error occurred (NotImplemented) when calling the PutBucketPolicy operation:
A header you provided implies functionality that is not implemented
Tigris doesn’t implement bucket policies. A bucket is either private or public, all of it. Make this one public and the staging uploads under _internal/ are public too: anyone with the address of one can read a bundle before it’s released, or one that’s been rolled back.
Patch’s answer is S3_INTERNAL_BUCKET. Set it, and every _internal/ key goes to a second bucket. The public bucket then holds only what phones need, and the private one holds the rest. By hand, I had to piece that together from the Compose guide. The wizard just creates the pair, maru-demo-patch public and maru-demo-patch-internal private, and sets both. If you ever set Patch up on another storage provider without bucket policies, do the same from the start.
The key check in step 3 is the test I ran by hand on maru’s buckets, as an anonymous visitor:
| Request | Result |
|---|---|
| A published file in the public bucket | 200 |
| Listing the public bucket | 403 |
| Anything in the private bucket | 403 |
| Listing the private bucket | 403 |
I’d half expected a public Tigris bucket to list its contents, but it doesn’t.
Tigris shows an access key’s secret only once. The wizard passes it straight into the Fly app’s secrets, so you never have to handle it. By hand, I lost mine: I’d filtered it out of my terminal so it wouldn’t end up in a log, which meant I never saw it. I had to delete the bucket and create one under a new name, because Tigris keeps a deleted bucket’s name reserved for a while.
The download address
PUBLIC_BASE_URL is the address phones download from. The wizard sets it to the public bucket’s own hostname, https://maru-demo-patch.t3.tigrisfiles.io, which is where Tigris serves public buckets now. maru’s, set up by hand earlier, uses the fly.storage.tigris.dev host instead. Both work the same way: the bucket is the hostname, not a folder.
That’s the one thing I got wrong in September. I wrote down PUBLIC_BASE_URL=https://myapp-patch.fly.storage.tigris.dev/codemagic-patch, copying the MinIO example, where /codemagic-patch is the bucket’s name in a path-style URL. Patch writes its files at the root of the bucket, so with that path on the end, every manifest would have pointed at files that aren’t there.
It matters because this address is built into every app binary, so a wrong one ships to the store and updates never arrive. The wizard proves the bucket answers at its address before it finishes, which rules out the mistake I made. It’s still worth one check after your first publish: fetch a manifest with curl, as below.
The database
If you let the wizard create the database, you get a second Fly app, maru-demo-patch-db: the official postgres:16-alpine image on a 1 GB volume, on a small shared machine that suspends when idle, like the server. Patch reaches it over Flycast, Fly’s private network through its proxy, at maru-demo-patch-db.flycast. That detail is what makes a sleeping database work. A .internal address goes straight to the machine and skips the proxy, so a suspended database would never wake up. If you paste your own URL, the wizard refuses a .internal one for that reason.
Fly takes a daily snapshot of the volume. That’s the only backup the install sets up, and the wizard says so when it finishes. cmpatch selfhost backup, at the end of this post, covers the rest.
1 GB goes a long way. Since 0.8 the server keeps individual install events for eight days and then folds them into daily totals, so the database grows with how many devices open your app and then levels off. The docs put it at about 2 GB per 100,000 daily active users. Plan more disk before it fills.
maru uses the other option. Its API already runs on Fly’s Managed Postgres, and a cluster costs the same whether it holds one database or several, so Patch got its own database and its own user on the existing cluster (fly mpg databases create and fly mpg users create, with the schema_admin role, because Patch runs its own migrations when it starts). Managed Postgres connects through PgBouncer in transaction mode. That breaks anything that needs one session to last across transactions, like session-level advisory locks, LISTEN and server-side cursors. maru ran into the cursor problem in September, so I checked what Patch’s server does first. Its migrations and metrics both use transaction-scoped advisory locks (pg_advisory_xact_lock), which are released when the transaction ends, so the pooler is fine.
If you don’t already have Postgres, let the wizard create it. A dedicated Managed Postgres cluster costs far more than the machine that uses it.
The server
This is the fly.toml the wizard writes, here for Maru Demo in Tokyo, from the run for this post:
app = "maru-demo-patch"
primary_region = "nrt"
[build]
dockerfile = "server/Dockerfile"
[env]
MODE = "all"
STORAGE_ADAPTER = "s3"
S3_BUCKET = "maru-demo-patch"
S3_INTERNAL_BUCKET = "maru-demo-patch-internal"
S3_ENDPOINT = "https://t3.storage.dev"
S3_FORCE_PATH_STYLE = "true"
S3_REGION = "auto"
PUBLIC_BASE_URL = "https://maru-demo-patch.t3.tigrisfiles.io"
INITIAL_ADMIN_EMAILS = "admin@example.com"
PATCH_SERVER_TAG = "codemagic-patch-server-v0.8.0"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = "suspend"
auto_start_machines = true
min_machines_running = 0
[[http_service.checks]]
method = "GET"
path = "/health"
interval = "15s"
timeout = "5s"
[[vm]]
cpu_kind = "shared"
cpus = 1
memory = "512mb"
fly config show on the running app reports exactly this, apart from the administrator’s email, which I’ve replaced here.
It’s nearly line for line what I wrote by hand for maru. Three settings are worth explaining.
The machine suspends when it’s idle. I wrote recently about Fly’s scale-to-zero turning into 504s, so for maru I started with it always on. The worry was the background worker. A publish only uploads a bundle and returns, and the worker does the real work afterwards. Fly decides a machine is idle by counting open requests, not by what it’s doing, so a machine that stops when idle can be stopped halfway through a release. Suspending is different. Fly snapshots the machine’s memory, so a job caught halfway is frozen and carries on when the machine resumes, instead of being killed. Phones only call the server to report installs, so it sleeps most of the time, and the next request wakes it in about 0.6 seconds on maru’s. min_machines_running has to be 0, or a lone machine never suspends.
The health check is /health, not /health/ready. The ready endpoint also checks the database, and I don’t want Fly taking the dashboard down over a database stall that phones would never notice.
There’s no published server image yet, so the wizard clones the newest server release tag and has Fly’s builder build it, as I did by hand. maru’s first deploy that way took 5 minutes 35 seconds. PATCH_SERVER_TAG records which release was built, so an upgrade knows what it’s upgrading from. Running, maru’s server uses 82 MB of its 512.
The wizard also makes the secrets: a random shared secret for the worker, a random key to sign CLI logins, plus the storage key, the database URL and the sign-in app’s credentials. It hands them to fly secrets import on standard input, so none of them lands in a command line, a file or your shell history.
Signing in
The server needs a sign-in provider before it will boot, and the first account is whoever signs in with the email you gave as administrator. Registration is invite-only from then on. GitHub won’t let anyone create an OAuth app through its API, so this is the one step that’s still a form in the browser. The wizard opens it and gives you the two values to paste:
- Homepage URL:
https://maru-demo-patch.fly.dev - Authorization callback URL:
https://maru-demo-patch.fly.dev/auth/callback
Then it asks for the client ID and secret, and checks a GitHub pair before deploying anything.
The administrator’s email has to be the verified primary email on that GitHub account. That’s what the server matches on, not the GitHub username.
The same rule applies to everyone you invite later, and this is where Patch is a little awkward for a team like mine. Everyone needs a GitHub, GitLab or Bitbucket account, and the invite only works if it goes to that account’s verified primary email. That’s normal for developers. It’s less normal for the person who decides when a release goes out, who in my case runs the product rather than the code. Creating a GitHub account isn’t hard, and it didn’t hold me up. But more ways to sign in, like an emailed link, would make it easier to get the dashboard in front of the people who decide what ships.
The CLI signs in through the same dashboard:
$ cmpatch login --server-url https://maru-demo-patch.fly.dev
Complete the sign-in in your browser.
Logged in as you@example.com
If you script against the dashboard, as I did for the screenshots in this post, note that a refresh token only works once. The dashboard and the CLI use the same kind of session, and the server replaces the refresh token every time it’s used. So if you copy the CLI’s session into a browser, the CLI gets logged out. That’s correct behaviour. For automation, use a personal access token instead:
$ cmpatch token create --name maru-ci
Apps, deployments and the first release
From here on, everything ran on maru’s own server, maru-patch.fly.dev. On a wizard install, cmpatch init creates the apps for you. I made maru’s by hand. Creating an app gives it a Staging and a Production deployment, each with a deployment key that gets built into the app binary:
$ cmpatch app create --name maru-ios
$ cmpatch app create --name maru-android
$ cmpatch deployment list --app maru-ios --format table
NAME DEPLOYMENT_KEY
Staging dep_5b0e2c41d8f94a7e9c3a61f02b7d8e19
Production dep_a91f3d07c6e24b58b0e4f7c2918d6a3b
Deployment keys aren’t secret, since anyone with the app has them. I published the release from the last post with the same script, now pointed at Fly:
═══ Patch OTA Publisher ═══
Platform: ios Deployment: Staging Server: https://maru-patch.fly.dev
Target binary version: 2.5.1
ios: bundling and releasing to maru-ios / Staging...
✓ ios: release v1 published (49s)
Most of the 49 seconds is Metro bundling the JavaScript. Then I did what a phone does, with no credentials:
$ curl -sI $BASE/dep_5b0e…/2.5.1/manifest.json
HTTP/1.1 200 OK
Cache-Control: no-cache, must-revalidate
Content-Type: application/json
$ curl -sI $BASE/dep_5b0e…/2.5.1/7c2e91ab…/bundle.tar.zst
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
Content-Length: 7301055
That’s the caching you want. Manifests are revalidated on every check, so a rollback reaches phones on their next check. A bundle’s URL contains its hash, so it can safely be cached for a year: a different bundle gets a different URL. The staging copy of the same release, in the private bucket, returns 403.
The Staging deployment on the Fly server. Both releases published; no phone is built against this deployment yet, so the counts are zero.
Code signing, from the first binary
New apps don’t require signed releases, and the publish script only signs when it’s given a private key. Last month I set up signing and tested it locally, but didn’t decide whether to use it in production. Setting up the server made the answer obvious. Once an app requires signing, a stolen CI token or storage key can’t push code to phones. The public key also has to be in the first binary that talks to the server, because it can’t be added over the air. No binary had been built against this server yet, so it had to be now:
$ openssl genrsa -out patch-production-private.pem 2048
$ openssl rsa -in patch-production-private.pem -pubout \
-out patch-production-public.pem
$ cmpatch app setting --app maru-ios --require-code-signing=true
$ cmpatch app setting --app maru-android --require-code-signing=true
Then the same publish, without a key:
This app requires signed releases. Rebuild the release with a code-signing
private key, for example `cmpatch bundle --private-key-path <pem>` …
And with one:
Signing: patch-production-private.pem
✓ ios: release v2 published (40s)
Release v2: signed, and the worker job that published it.
The public key goes into the build profiles as one base64 line, next to the server URL, the download URL and the deployment keys. The private key goes in a password manager and in CI as a secret, and nowhere else.
The status page checks the database, storage and both public URLs. This is maru’s hand-made server on 0.5.1; the wizard install’s is near the top of this post.
Over the air, before the store
People usually talk about OTA updates as a way to fix an app that’s already on users’ phones. They’re just as useful before release, for getting new code onto a test device without a full native build.
In a team like mine, that’s a real cost. Until now, when the person who runs the product wanted to try a change on their phone, it took a native build every time: about fifteen minutes on my machine, then an upload, TestFlight processing and an install. Even for a one-line copy change. So small changes get batched up, and feedback arrives days after the code was written.
With Patch, I added a third deployment next to Staging and Production, for builds given to testers:
$ cmpatch deployment create --app maru-ios --name Development
"deployment_key": "dep_e27c9b5a10f3486d9a1b4c8e6f02d7a5"
Build the tester’s app once with that deployment’s key. Here it’s maru 2.5.7, the first version with Patch inside. Then check the values actually made it into the binary, because a build without them will never update and won’t tell you:
CodemagicPatchApiUrl https://maru-patch.fly.dev
CodemagicPatchDeploymentKey dep_e27c9b5a10f3486d9a1b4c8e6f02d7a5
CodemagicPatchDownloadBaseUrl https://maru-patch-artifacts.fly.storage.tigris.dev
CodemagicPatchPublicKey MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…
CFBundleShortVersionString 2.5.7
That build took 13 minutes 37 seconds with a warm cache. An earlier build from a cold cache took 18 minutes 24 seconds.
The tester’s build, as installed: “Join the community”.
Then the change: one line of copy, the kind of feedback you get from someone trying the app on their phone. “Join the community” should say “Join your neighbours on maru”. Edit it, and publish to Development:
$ ./scripts/publish-patch-update.sh ios --development \
--notes "Copy change for testers: Join your neighbours on maru"
Platform: ios Deployment: Development Server: https://maru-patch.fly.dev
Signing: patch-production-private.pem
Target binary version: 2.5.7
✓ ios: release v1 published (94s)
That took longer than the usual 40 to 50 seconds, mostly because Metro was bundling in a fresh checkout. On the tester’s phone, reopen the app:
Reopen, “Update ready”, restart: the new copy, 33 seconds after opening the app. No build, no TestFlight.
After the restart: “Join your neighbours on maru”.
| Getting a JavaScript change onto a tester’s phone | Time |
|---|---|
| Native Release build, warm cache | 13 min 37 s |
| Native Release build, cold cache | 18 min 24 s |
| … then upload, TestFlight processing, install | minutes more, and a person in the loop |
| Patch: publish to Development (bundle, sign, upload, publish) | 40–95 s |
| Patch: the phone picks it up after reopening the app | 33 s |
A few things I learned along the way.
The first attempt didn’t update, and that was my app’s setting, not Patch’s. maru checks for updates when it launches and when it comes back to the foreground, but at most once every fifteen minutes. That’s right for everyday users. But for a tester waiting on a fix, switching away and back does nothing for fifteen minutes. Closing and reopening the app works. If your test builds use their own profile, as mine do, give that profile a shorter interval, or none.
Only JavaScript changes can go over the air. React Native OTA Updates: What You Can (and Can’t) Deploy Over the Air is the full list. The CLI fingerprints the native project, and the server rejects a release whose native code doesn’t match the binary (with a 409). So a change that adds a native module can’t reach a tester’s phone this way by accident. It needs a new build, which is correct. In practice, almost all tester feedback is about copy, layout or logic.
Signing is per app, not per deployment. I turned on signing for maru-ios, so Development releases are signed with the production key too. That’s fine for me, since the key is on the machine I publish from anyway. If your developers need to publish to Development without the production key, give the tester build its own app, say maru-ios-dev, with its own key. The downside is that you can’t promote releases between the two apps.
Promotion is the part I like most. The tester’s build is the same version as the store build, so a release the tester approved is a bundle the server already has. Last month, promoting a release from Staging to Production reused the exact same package, with the same hash and notes, instead of rebuilding it. Every release in Development has the same Promote button. So users can get exactly the bytes a tester checked, not a rebuild of the same commit.
Moving maru itself
The server is only half the job. Phones running maru 2.5.6 and earlier use expo-updates and get their updates from my old Django server, so nothing published to Patch can reach them. Moving maru over needed a store build with the Patch SDK inside, and that was 2.5.7.
The Patch work was on a branch from 2.5.1, and main had moved on three weeks since then. Merging it back caused conflicts in seven files, all to do with the update system: expo-updates coming out of package.json, app.config.js, Expo.plist and Android’s resources, a test that mocked it, and the two lockfiles, which I regenerated rather than merged. Nothing in the app’s own screens conflicted. I also upgraded the SDK from 0.3.0 to 0.5.0 to match the server, and the typecheck passed without changes.
The one thing that would have broken without a conflict was a file added to main after the branch. It reads the expo-updates runtime version to tell my API which build is calling. With the package gone, Metro would have failed to bundle. Patch builds now send patch:2.5.7 in that header instead. That turns out to be useful: my API’s records show which builds are still on expo-updates, so I’ll know when the last one is gone and the old update server can be switched off. Until then it stays up.
Two weeks in production
2.5.7 went to both stores on the evening of 5 October. Before submitting, I checked the Patch values inside the built binaries rather than trusting the build config: plutil on the Info.plist in the .ipa, and a plain search of the Android bundle’s resources for the deployment key. Both carried the Production keys, and neither carried a Staging one.
The first production publish was refused. The production apps require signed releases, and I’d run the script without the key, so that’s the guard doing its job. Two things made it messier than it needed to be. --dry-run had passed, because a dry run doesn’t ask the server what it requires. And I had two private keys on disk, an evaluation one and a production one, with nothing in the CLI to say which one matched the public key in 2.5.7. I compared them with openssl by hand.
With the right key, release v1 went out to both platforms at 10%, signed. It was a real fix, not a test: Korean text in italics stood upright on iPhone, because iOS has no italic for its Korean font. App Store Connect gives every uploaded build to internal testers before review, so a TestFlight iPhone on 2.5.7 picked up the release within minutes: downloaded 1, ready 1, failed 0. I raised iOS to 100% three minutes later.
Over the next eight days I published about ten production releases across 2.5.7, 2.5.9 and 2.5.10: fixes and small changes that would each have waited for a store review before. One publish failed with an HTTP 500, Connection terminated unexpectedly, when a database connection dropped on the server’s side; the same command a minute later went through. The fingerprint guard refused two releases whose native project no longer matched the binary they targeted, which is what it’s for.
The first native upgrade since the cutover, Expo SDK 58 as maru 2.6.0, turned up the one thing that would have quietly switched OTA off. The Android release build came out with none of the four Patch values in it. The resource shrinker in Android Gradle Plugin 9 is much stricter than before. The SDK reads its configuration by resource name, which the shrinker can’t see, so it removed them. A missing value reads as empty, and the SDK treats that as “no OTA”. Nothing would have errored: Android 2.6.0 would have installed, run and never received an update. A keep rule for @string/CodemagicPatch* fixed it. Since then, the React Native SDK 0.6.0 (28 October) ships that keep rule itself and logs a warning on Android when the deployment key is missing, so on 0.6.0 or later you don’t need your own. Still, check the values inside every release binary, not just the first one.
Upgrades and backups
maru’s hand-made server upgrades the way I set it up: my own script clones the new release tag and runs fly deploy. On a wizard install, it’s one command from the machine that ran the install:
$ cmpatch selfhost upgrade
It builds the newest server release on Fly and deploys it with the app’s current configuration. Before deploying, it snapshots the database volume (when the wizard created the database) and writes a folder with a ROLLBACK.md under codemagic-patch-backups/maru-demo-patch.fly.dev/, in the directory you ran it from. It doesn’t roll back on its own; if you need to go back, that file has the commands, with your snapshot and image already filled in. With your own database there’s no snapshot, so take a backup of it first.
0.8.0 is the newest release as I write, so there was nothing to upgrade to yet. Backups I could try, and they’re one command too. On the new server, after signing in and creating one app, maru-ios:
$ cmpatch selfhost backup
◇ Checking maru-demo-patch on Fly [2s]
◇ Checking maru-demo-patch-db [0s]
◇ Dumping the database on 8d96e69a107078 [6s]
◇ Downloading the dump [3s]
◇ Saving maru-demo-patch's release config [4s]
└ Backup complete.
Backup of maru-demo-patch saved on this machine (fly/1, database):
codemagic-patch-backups/maru-demo-patch.fly.dev/maru-demo-patch-2026-10-28T16-05-48
Database: maru-demo-patch-db (1 MB, 19 migrations, Postgres 16.15)
Server: codemagic-patch-server-v0.8.0 (registry.fly.io/maru-demo-patch:deployment-01M61KP4ZP15RY3MQ8B6H6CJWB)
Restore it with: cmpatch selfhost restore maru-demo-patch-2026-10-28T16-05-48
It dumps the database inside its Postgres machine while Patch keeps running, and saves three files in the directory you ran it from: the dump, the app’s fly.toml and a manifest. It doesn’t include the release files in the buckets or the app’s secrets, so copy the folder somewhere other than your laptop and keep the secrets in your password manager.
To check a restore actually works, I created a second app, maru-android, after the backup, and then restored it:
$ cmpatch selfhost restore
◇ Which backup do you want to restore?
│ maru-demo-patch-2026-10-28T16-05-48 (2026-10-28T16:05:48.067Z, database,
│ codemagic-patch-server-v0.8.0)
…
◇ Type "restore" to continue
│ restore
◇ Uploading the dump to 8d96e69a107078 [7s]
◇ Holding maru-demo-patch (no machines while the database is replaced) [4s]
◇ Snapshotting vol_40o33wwe9y9pgmp4 in maru-demo-patch-db [51s]
◇ Replacing the database [3s]
◇ Restoring the backup of 2026-10-28T16:05:48.067Z [3s]
◇ Checking the restored database [3s]
◇ Deploying codemagic-patch-server-v0.8.0 again on the restored database [31s]
◇ Waiting for https://maru-demo-patch.fly.dev/health/ready [11s]
Your data is restored. Sign in again with `cmpatch login`, then publish a release to confirm end to end.
$ cmpatch app list --format table
ID NAME CODE SIGNING
app_97bf7285a6124a8d9440be2b5ac0c910 maru-ios no
maru-android is gone and maru-ios is back, as it should be. Before you type restore, it spells out what’s about to happen: everything published after the backup goes, the files in the buckets are not rolled back, and the dashboard, the API and publishing are down until it finishes, while phones keep downloading updates from storage. It snapshots the database volume first, so the restore itself can be undone. The whole thing took about two and a half minutes, most of it that snapshot and the server coming back up. Afterwards the CLI was signed out, which the last line tells you about.
To run these commands from another machine, record the install there first. This creates nothing on Fly:
$ cmpatch selfhost install --fly --register --company "Maru Demo"
What it costs
| Monthly | |
|---|---|
| Fly machine for Patch, shared CPU, 512 MB, suspended when idle | under US$5 |
| Postgres, created by the wizard: a second machine the same size, also suspended, and a 1 GB volume | under US$5, plus 15 cents for the volume |
| Postgres on maru: a database on the cluster it already pays for | nothing extra |
| Tigris storage and requests | cents |
The machine prices are ceilings: what one costs if it never sleeps. That’s US$3.69 a month in Fly’s cheapest regions, and US$4.83 in Tokyo, where I ran the wizard. Fly bills a running machine by the second, and a suspended one only for its disk, at 15 cents a GB a month. Phones fetch their updates from Tigris, so these machines only wake when someone publishes, opens the dashboard, or a phone reports an install. Asleep, the wizard’s whole install costs about 25 cents a month, mostly the database’s volume. Awake two hours a day, it’s about a dollar. Fly’s daily volume snapshots are free up to 10 GB, and Tigris’s free allowance (5 GB stored, 10,000 writes and 100,000 reads a month) covers a small app’s updates.
My own expo-updates server cost about a dollar a month, because it was just a few rows in a database and some files in a bucket I already had. Patch costs a few dollars more, and for that I get a dashboard, staged rollouts, crash rollback, install metrics, signing, and a codebase I don’t have to maintain.
Where this leaves things
When I set up maru’s server, it took me an afternoon and some reading between the lines: a second bucket because Tigris has no policies, the download address as a hostname, suspend rather than stop, a health check that ignores the database. The wizard now does all of that in one command, and checks its own work before it hands you the addresses. The only step left in the browser is the sign-in app, because GitHub doesn’t offer a way to create one from the command line. After that, the one thing no installer can do for you is check the values inside your first binary.
The best part of the design is still the one I noticed first: phones never ask the API whether there’s an update. That’s why the server runs fine on one of Fly’s smallest machines, why the server going down wouldn’t stop a single phone from updating, and why a tester’s build picked up a change half a minute after it was published. The rough spot I’d still point out is sign-in for people who don’t use GitHub, GitLab or Bitbucket.
If you want to try it, the install docs cover the Fly install, and the Patch docs start with a local evaluation stack you can run on a laptop first.
maru has been running on Patch in production since 5 October, with more than twenty releases per platform by the end of the month. I’ll write more about how that goes over the coming months: rollouts and rollbacks with more users behind them, and what running it yourself is like after a few months.
FAQ
How do I install Codemagic Patch on Fly.io?
Install the CLI, then run the Fly installer on your laptop:
npm install -g @codemagic/patch-cli
cmpatch selfhost install --fly
It asks for a company name, a region, and whether you already have Postgres. It creates the Fly app, two Tigris buckets, and a Postgres machine if you need one. You sign in to Fly and Tigris in the browser, and create a GitHub, GitLab, or Bitbucket sign-in app.
How much does Codemagic Patch on Fly.io cost?
A shared 512 MB machine that suspends when idle is under US$5 a month. A Postgres machine created by the wizard is about the same, plus 15 cents for a 1 GB volume. Asleep, the whole install is about 25 cents a month. Tigris storage for a small app stays within the free allowance. A database on a Postgres cluster you already pay for costs nothing extra.
Do phones talk to the Patch server to check for updates?
Phones download manifests and bundles from the public Tigris bucket. They contact the server only to report how an install went. If the machine is asleep or down, phones still get the last published release.
