Codemagic Patch is a self-hosted over-the-air (OTA) update service for React Native apps. Ship JavaScript/asset updates straight to installed apps β no app-store review for changes that live in your JS bundle.
This monorepo contains everything you need to run the service yourself and wire it into an app:
- a server (control plane + release worker),
- a React Native client SDK (
@codemagic/react-native-patch) with an Expo config plugin, - a CLI (
cmpatch) for publishing and managing releases, - a web dashboard, and
- a one-command Docker Compose self-host stack.
Evaluate the full service on your machine before provisioning domains or OAuth: the local evaluation stack runs the real server, worker, Postgres, MinIO, and dashboard, with sign-in replaced by a local one-click login. The prerequisites are Docker (with Compose v2), Node.js 20.19+ or 22.12+ (with npm), git, and curl, on macOS, Linux, or WSL 2.
With the CLI installed, one command does everything β it keeps its own copy of this repository, checks Docker first (and offers to install or start it), brings the stack up, and points cmpatch at it:
npm install -g @codemagic/patch-cli
cmpatch selfhost local-evalOr from a clone of this repository:
git clone https://github.com/codemagic-ci-cd/codemagic-patch.git
cd codemagic-patch
./scripts/local-eval/up.shEither way the stack comes up with a seeded demo app:
- Dashboard β http://localhost:8080 (sign in with the prefilled one-click local login)
- API β http://localhost:3000; the CLI makes it the default when no higher-precedence project or environment setting overrides it, and prints
--server-urlguidance otherwise cmpatch selfhost local-eval statusshows the rest: services, the MinIO console, the seeded API token, and a sample publish command
To see an update apply on a running app (iOS simulator / Android emulator), continue with the on-device demo.
The evaluation stack is defined in docker-compose.dev.yml (not the self-host compose file). Tear everything down with cmpatch selfhost local-eval down (data is kept for the next start; add --delete-data to drop it too), or from a clone:
docker compose -f docker-compose.dev.yml down -v
β οΈ Evaluation only β not a deployment. Authentication is disabled and all ports bind to localhost. For a production deployment, follow Part 1 β Run the server (self-host).
Getting started
Production setup
- Part 1 β Run the server (self-host)
- Part 2 β Install the CLI and sign in
- Part 3 β Create apps & deployments
- Part 4 β Connect your React Native app
- Part 5 β Publish your first release
Release management & operations
Reference
Guides
Developer / CI Self-host server Installed app
ββββββββββββββββ release ββββββββββββββββββββ manifest ββββββββββββββββ
β cmpatch β ββββββββββββΊ β API + worker β βββββββββββ β react-native β
β release-... β upload β (Fastify) β download β -patch SDK β
ββββββββββββββββ β Postgres + S3 β βββββββββββΊ β swaps bundleβ
ββββββββββββββββββββ artifacts ββββββββββββββββ
β²
β HTTPS, TLS, dashboard
ββββββ΄βββββ
β Caddy β
βββββββββββ
- You publish a release with the CLI. It bundles your JS, computes a native fingerprint, resolves a target binary version, and uploads the bundle to the server, which stores the artifact and a manifest in object storage.
- On launch (or resume), the SDK fetches the manifest for its deployment + binary version, downloads the new bundle (or a smaller binary patch when available), and swaps it in on the next restart.
- The SDK reports download/install/success/failure metrics back to the server.
The default self-host stack runs four services on a single Docker host:
| Service | Role |
|---|---|
| Caddy | HTTPS/TLS (Let's Encrypt), API reverse proxy, dashboard, storage-domain proxy |
| Server | API + release worker in one process (MODE=all) |
| PostgreSQL | Control-plane data: apps, deployments, releases, IAM, metrics |
| MinIO | S3-compatible object storage for public artifacts and internal uploads |
| Concept | What it is |
|---|---|
| App | A logical application. Use a separate app per platform (e.g. MyApp-iOS, MyApp-Android). |
| Deployment | A release channel inside an app. Every app is created with Staging and Production. |
| Deployment key | The public identifier the SDK uses to fetch updates. Found via cmpatch deployment list. Not a secret β it's baked into the app binary. |
| Release | A published bundle targeting one deployment + binary version. Identified by a label like v1. Supports gradual rollout, mandatory updates, and rollback. |
| Binary version | The native app version a release targets (e.g. 1.2.3). The SDK only installs releases that match the running binary version. |
| Fingerprint | A hash of the native project. Guards against shipping a JS bundle to an incompatible native binary. |
β οΈ Always use separate deployment keys for iOS and Android. The manifest path does not include the platform, so reusing one key across both platforms (with the same binary version) lets releases overwrite each other.
These requirements apply to a production self-host deployment. The local Quickstart requires only Docker and Node.js.
Server host
- Docker + Docker Compose v2, and
curl - Public inbound access on ports 80 and 443
- Two hostnames with DNS A/AAAA records pointing at the host β one for the API/dashboard, one for artifact storage. Two subdomains of a domain you already own are enough; they just have to differ:
- API/dashboard β e.g.
updates.example.com - Storage β e.g.
storage-updates.example.com
- API/dashboard β e.g.
- A GitHub OAuth App (see below)
Using the CLI
- Node.js
20.19+or22.12+ - Building from this repo instead requires Node.js
>=22.20.0and Yarn4.12.0(via Corepack)
React Native app
- React Native
>=0.73, React>=18β New Architecture support starts at RN 0.76; RN 0.73β0.75 are supported on the Old (Paper) Architecture only - Android
minSdkVersion23+ - Expo: SDK 52+ via the bundled config plugin (prebuild / development builds). Expo Go is not supported β the native module is not part of the Expo Go runtime.
If you have any questions or need help with Patch, join the Codemagic Discord. Our developers will be available to help out or take feature requests.
Condensed setup. The full self-hosting guide β smoke checks, storage hardening, and operational detail β is
docs/self-hosting-compose.md.
Install the CLI on your own machine (Node.js 20.19+ or 22.12+) and run the guided install:
npm install -g @codemagic/patch-cli
cmpatch selfhost installThe command asks for everything it needs β the server to connect to over SSH, the two hostnames, the administrator email, the GitHub OAuth App β and waits at each step until it checks out, so there is nothing to prepare in advance. It offers to install Docker, git, and curl on the server if they are missing, then runs the installer over SSH. Building the images takes around twenty minutes on a cold cache.
Sign-in (both cmpatch login and the dashboard) is backed by GitHub OAuth through the browser. You do not need to create the OAuth App in advance: the installer opens a pre-filled registration form at the right moment and checks the credentials with GitHub before it builds anything.
The first admin's email must exactly match the verified primary email on their GitHub account. The default registration mode is
invite_only, so the very first sign-in is rejected if it doesn't match.
Bitbucket Cloud is also supported as a sign-in provider, instead of or alongside GitHub, through the on-server install path β see Bitbucket sign-in in the full guide.
By default clients download directly from the storage domain (DELIVERY_ADAPTER=base-url), which is fine for trying Patch. For production deployments we strongly recommend a CDN, since otherwise every device downloads from the single storage host: front the storage domain (only β the API domain stays direct) with a CDN: artifacts and manifests are then served from the edge, and after every release, promotion, rollback, and deployment clear the server purges the affected meta.json/manifest.json URLs so clients don't see stale manifests.
cmpatch selfhost install offers this as a step. It lists Cloudflare when your storage domain is already on a Cloudflare account, and CloudFront always, and walks you through the token, the DNS proxy switch, and the cache rule, checking each one. The by-hand reference for Cloudflare is Β§1.5; for CloudFront (which needs an ACM certificate in us-east-1, a distribution-scoped purge IAM key, and a separate protected origin hostname) follow the CloudFront setup guide.
Run the install script on the host
The CLI is a wrapper around a script in the repository. To run it on the host yourself, create the OAuth App first with Homepage URL https://updates.example.com and callback URL https://updates.example.com/auth/callback, add the two DNS records, then:
git clone https://github.com/codemagic-ci-cd/codemagic-patch.git
cd codemagic-patch
scripts/selfhost/install.sh \
--api-domain updates.example.com \
--storage-domain storage-updates.example.com \
--email admin@example.com \
--github-oauth-client-id <github_client_id> \
--github-oauth-client-secret <github_client_secret>For Cloudflare, add --cloudflare --cloudflare-api-token <cf_cache_purge_token> --cloudflare-zone-id <cf_zone_id> (a token with the single permission Zone β Cache Purge β Purge, and the zone's ID from its Overview page).
Either way, the installer:
- writes
.env.selfhostwith strong random secrets for Postgres, MinIO, the worker, and OAuth (it refuses to overwrite an existing file), - builds the server and Caddy (dashboard) images,
- starts the Compose stack under project name
codemagic-patch-selfhost, - waits for Caddy to obtain Let's Encrypt certificates (1β2 min) by polling
/healthand storage health, and - prepares the single fixed
default-teamon first boot.
When it finishes you'll have:
Dashboard: https://updates.example.com/
API URL: https://updates.example.com (app config: CodemagicPatchApiUrl)
Download base: https://storage-updates.example.com/codemagic-patch (app config: CodemagicPatchDownloadBaseUrl)
π
.env.selfhostholds production secrets. Back it up and never commit or expose it.
curl -fsS https://updates.example.com/health
curl -fsS https://storage-updates.example.com/minio/health/ready
# Unauthenticated smoke test
scripts/selfhost/smoke.sh
# After you create an API token (Part 2), run the full publish smoke test
CODEMAGIC_PATCH_TOKEN=cm_pat_xxx scripts/selfhost/smoke.shThe guided install walks you through these steps itself. They are here for on-server installs and for adding Cloudflare later. Once the server is installed with the Cloudflare settings, the server side is already active β releases request edge purges.
- Keep the storage domain DNS-only (grey cloud) until the installer reports storage HTTPS as ready β Caddy needs to obtain its Let's Encrypt certificate first.
- Switch the storage record to Proxied (orange cloud).
- Set the zone's SSL/TLS mode to Full (strict). The origin serves a valid Let's Encrypt certificate, so strict validation works; Flexible would connect to the origin over HTTP, which Caddy redirects back to HTTPS and can cause a redirect loop.
If a later certificate renewal fails while proxied, temporarily switch the record back to DNS-only, let Caddy renew, then re-enable the proxy.
Create one rule under <zone> β Caching β Cache Rules to make the storage hostname eligible for caching:
| Rule expression | Cache eligibility | Edge TTL |
|---|---|---|
http.host eq "storage-updates.example.com" |
Eligible for cache | Use cache-control header if present, bypass cache if not |
Cloudflare does not cache JSON by default, so the eligibility rule remains
necessary. With Origin Cache Control, it honors Patch's origin headers:
artifacts are content-addressed and immutable for a year, while manifests use
public, max-age=0, s-maxage=300, must-revalidate. Clients revalidate
immediately and the edge may retain JSON for five minutes. Do not add an Edge
TTL override: the Free-plan two-hour minimum applies to that override, not to
the origin s-maxage directive.
Upgrading an existing zone: earlier versions of this guide had you add a
second rule matching .json paths with Ignore cache-control header and use
this TTL: 2 hours. Delete that rule β an Edge TTL override takes
precedence over the origin s-maxage, so leaving it in place keeps manifests
stale at the edge for up to two hours after a failed purge instead of five
minutes.
# Second request should return "cf-cache-status: HIT"
DEPLOYMENT_KEY=your-deployment-key
URL="https://storage-updates.example.com/codemagic-patch/${DEPLOYMENT_KEY}/meta.json"
curl -sI "$URL" | grep -i cf-cache-status
curl -sI "$URL" | grep -i cf-cache-statusAfter publishing a release, a successful purge makes the same URL briefly report MISS again. Purging is best-effort: a failed purge never fails the release β it is logged as a delivery cache purge completed with failures warning in the server logs, so watch for that warning if clients report stale updates.
Rerunning the installer with --cloudflare flags does not change an existing install β delivery configuration is only written on initial install, and the rerun prints a warning instead. To enable it later, edit .env.selfhost:
DELIVERY_ADAPTER=cloudflare
CLOUDFLARE_API_TOKEN=<cf_cache_purge_token>
CLOUDFLARE_ZONE_ID=<cf_zone_id>Then rerun scripts/selfhost/install.sh β it re-reads the file, verifies the credentials, and restarts the stack. Finish with steps 1β3 above.
You can do everything from the dashboard, but CI and scripting use the CLI. Install it globally from npm (requires Node.js 20.19+ or 22.12+):
npm install -g @codemagic/patch-cliAlternatively, build it from this repo (requires Node.js >=22.20.0):
corepack enable
yarn install
yarn cli:install-global # builds and installs the `cmpatch` binary globallySign in as the admin (GitHub sign-in and approval complete in the browser):
cmpatch login --server-url https://updates.example.comcmpatch init (Part 3) stores the server URL in the project, so later commands
need no --server-url. Outside a project, cmpatch config set server-url <url>
stores it per user. There is no team to configure: the CLI resolves the server's
single default team automatically.
Create an API token for CI:
cmpatch token create --name ciThe cm_pat_... value is shown once. Store it as a CI secret and supply it via the CODEMAGIC_PATCH_TOKEN env var or --token.
Auth precedence:
--tokenβCODEMAGIC_PATCH_TOKENβ the credential saved bycmpatch login(stored in~/.codemagic-patch/).
From your React Native project root, let the CLI do it:
cmpatch initIt connects to the server, signs you in if needed, creates or selects one app per platform β each with Staging and Production deployments β and writes codemagic-patch.config.json so later commands can omit --server-url and --app.
To manage apps by hand instead, keep iOS and Android in separate apps:
cmpatch app create --name MyApp-iOS
cmpatch app create --name MyApp-AndroidEither way, deployment list shows the keys your app embeds (CodemagicPatchDeploymentKey); the same operations are available in the dashboard at https://updates.example.com/:
cmpatch deployment list --app MyApp-iOS --format table
cmpatch deployment list --app MyApp-Android --format tableMigrating from CodePush? The migration guide maps
react-native-code-pushnative config, JS APIs, andcode-pushCLI commands to their Codemagic Patch equivalents.
Add the SDK:
yarn add @codemagic/react-native-patchThe SDK is configured through four native values (injected at build time):
| App config key | Value |
|---|---|
CodemagicPatchDeploymentKey |
the deployment key from cmpatch deployment list |
CodemagicPatchDownloadBaseUrl |
your Download base URL (ends with /codemagic-patch) |
CodemagicPatchApiUrl |
your API URL |
CodemagicPatchPublicKey |
(optional) PEM public key for code-signing enforcement |
The snippets below use placeholder values (
ios-staging-deployment-key,https://updates.example.com, β¦) β substitute your own deployment keys and URLs from Parts 1 and 3.
Wire the config and native bundle selection manually.
iOS
-
Install the native pod:
cd ios && pod install && cd ..
-
Add
CodemagicPatchDeploymentKey,CodemagicPatchDownloadBaseUrl,CodemagicPatchApiUrltoios/<YourApp>/Info.plist:<key>CodemagicPatchDeploymentKey</key> <string>ios-staging-deployment-key</string> <key>CodemagicPatchDownloadBaseUrl</key> <string>https://storage-updates.example.com/codemagic-patch</string> <key>CodemagicPatchApiUrl</key> <string>https://updates.example.com</string> <!-- optional, only when enforcing code signing --> <key>CodemagicPatchPublicKey</key> <string>-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----</string>
-
In your AppDelegate, override the bundle URL so the app prefers the OTA bundle and falls back to the embedded bundle. Keep the
DEBUGbranch pointing at Metro so local development keeps working. On the Swift AppDelegate (RN 0.77+ template):import CodemagicPatchClient class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { override func sourceURL(for bridge: RCTBridge) -> URL? { self.bundleURL() } override func bundleURL() -> URL? { #if DEBUG RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") #else CodemagicPatch.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle") #endif } }
On RN β€ 0.76, where the app template still ships an Objective-C++
AppDelegate.mm, overridesourceURLForBridge:with the same selection β seeclient/README.mdΒ§Configuration for the forward-declaration snippet.Reference:
client/plugin/src/withIosBundleURL.ts
Android
- Add the same keys to
android/app/src/main/res/values/strings.xml:<resources> <string name="CodemagicPatchDeploymentKey" translatable="false">android-staging-deployment-key</string> <string name="CodemagicPatchDownloadBaseUrl" translatable="false">https://storage-updates.example.com/codemagic-patch</string> <string name="CodemagicPatchApiUrl" translatable="false">https://updates.example.com</string> <!-- optional, only when enforcing code signing --> <string name="CodemagicPatchPublicKey" translatable="false">-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----</string> </resources>
- In
MainApplication.kt, feed the SDK's bundle path into React Native. On RN β€ 0.81 (ReactNativeHost), overridegetJSBundleFile()inside the host object:On RN β₯ 0.82 (import io.codemagic.patch.CodemagicPatch // ... override val reactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(this) { // ...existing overrides... override fun getJSBundleFile(): String? = CodemagicPatch.getJSBundleFile(applicationContext) }
reactHostviagetDefaultReactHost), pass it asjsBundleFilePath:Reference:import io.codemagic.patch.CodemagicPatch // ... override val reactHost: ReactHost by lazy { getDefaultReactHost( context = applicationContext, packageList = PackageList(this).packages, jsBundleFilePath = CodemagicPatch.getJSBundleFile(applicationContext), ) }
client/plugin/src/withAndroidBundleFile.ts
Debug builds load JS from Metro, so OTA updates are not picked up there β that is expected, not a wiring problem. To see an update apply, run a release-style build (
npx react-native run-ios --mode Release/npx react-native run-android --mode release).
Requires Expo SDK 52+ and a prebuild / development-build workflow β Expo Go is not supported.
Add the config plugin to app.json / app.config.js:
{
"expo": {
"plugins": [
[
"@codemagic/react-native-patch",
{
"ios": {
"deploymentKey": "ios-staging-deployment-key",
"downloadBaseUrl": "https://storage-updates.example.com/codemagic-patch",
"apiUrl": "https://updates.example.com"
},
"android": {
"deploymentKey": "android-staging-deployment-key",
"downloadBaseUrl": "https://storage-updates.example.com/codemagic-patch",
"apiUrl": "https://updates.example.com"
}
}
]
]
}
}Then regenerate native projects:
npx expo prebuild
cd ios && pod install && cd ..The plugin injects the config keys (iOS Info.plist, Android strings.xml) and wires native bundle selection for you β the same wiring shown in Option A:
- iOS AppDelegate β prefers
CodemagicPatch.bundleURL(), falling back to the embedded bundle. TheDEBUG/ Metro branch is left untouched, so local development keeps working. - Android MainApplication β prefers
CodemagicPatch.getJSBundleFile(applicationContext)(both the RN β€ 0.81getJSBundleFile()and RN β₯ 0.82jsBundleFilePathhost shapes are handled).
sync() is the one call most apps need. On each invocation it runs the whole update flow in order:
notifyAppReady()β marks the currently running bundle as healthy. This is the SDK's rollback protection: if a freshly installed bundle crashes beforesync()(and thereforenotifyAppReady()) runs, the next launch automatically reverts to the last known-good bundle. Becausesync()calls it first, simply runningsync()on every startup confirms the previous update and arms rollback for the next one β you don't have to call it yourself.- Check the server for an update matching this app's deployment key + binary version.
- Download the new bundle (or a smaller binary patch, with automatic fallback to the full bundle).
- Install it according to the chosen install mode (see below).
sync() never throws β it always resolves to a SyncStatus string, so you can branch on the result instead of wrapping it in try/catch.
Call sync() once, as early as possible after your root component mounts. This is enough to get OTA updates working end to end.
// App.tsx
import { useEffect } from "react";
import { sync } from "@codemagic/react-native-patch";
export default function App() {
useEffect(() => {
// Fire-and-forget: sync() handles its own errors and resolves to a status.
void sync();
}, []);
return <YourApp />;
}With no options, non-mandatory updates install on the next app restart and mandatory updates install immediately. The user gets the new bundle the next time they cold-start the app.
The install mode controls when a downloaded bundle becomes active. Mandatory releases (published with --mandatory) use mandatoryInstallMode; everything else uses installMode.
| Install mode | When the new bundle becomes active |
|---|---|
ON_NEXT_RESTART |
On the next cold start (default for non-mandatory). Least disruptive. |
ON_NEXT_RESUME |
When the app returns to the foreground after being backgrounded for minimumBackgroundDuration. |
ON_NEXT_SUSPEND |
When the app goes to the background (after minimumBackgroundDuration). |
IMMEDIATE |
Right away β the JS bundle reloads as soon as install finishes (default for mandatory). |
void sync({
installMode: "ON_NEXT_RESTART", // optional updates: wait for a natural restart
mandatoryInstallMode: "IMMEDIATE", // forced updates: reload now
minimumBackgroundDuration: 60_000, // for ON_NEXT_RESUME/SUSPEND, in ms
});Re-running
sync()when the app returns to the foreground catches updates published while the user had the app open. Wire it toAppState:import { AppState } from "react-native"; import { sync } from "@codemagic/react-native-patch"; AppState.addEventListener("change", (next) => { if (next === "active") void sync(); });
sync() resolves to one of: "up-to-date", "update-installed", "embedded-revert-applied", "sync-in-progress", or "error". The optional second argument is a progress callback ({ receivedBytes, totalBytes }) you can use to drive a UI.
import { useEffect, useState } from "react";
import { sync, type SyncStatus } from "@codemagic/react-native-patch";
export function useOtaUpdate() {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<SyncStatus>();
useEffect(() => {
void (async () => {
const result = await sync(
{ installMode: "ON_NEXT_RESTART", mandatoryInstallMode: "IMMEDIATE" },
({ receivedBytes, totalBytes }) => {
setProgress(totalBytes > 0 ? receivedBytes / totalBytes : 0);
},
);
setStatus(result);
switch (result) {
case "update-installed":
// Downloaded and staged. For ON_NEXT_RESTART it applies on the next launch.
break;
case "up-to-date":
case "embedded-revert-applied":
case "sync-in-progress":
break;
case "error":
// Safe to ignore β the app keeps running the current bundle.
break;
}
})();
}, []);
return { progress, status };
}If you need to separate the steps β e.g. download silently but let the user decide when to restart, or gate updates behind a "What's new" prompt β use the lower-level functions instead of sync():
import {
checkForUpdate,
downloadUpdate,
installUpdate,
notifyAppReady,
restartApp,
disallowRestart,
allowRestart,
} from "@codemagic/react-native-patch";
// 1) Confirm the running bundle is healthy (arms rollback). Call this once on
// startup if you are NOT using sync(), e.g. after your app finishes booting.
await notifyAppReady();
// 2) Check, then download with progress.
const check = await checkForUpdate();
if (check.action === "ota-update") {
const local = await downloadUpdate(check.remotePackage, (p) =>
console.log(p.receivedBytes, "/", p.totalBytes),
);
// 3) Install. With IMMEDIATE the bundle reloads now; with ON_NEXT_RESTART it
// waits for the next launch.
await installUpdate(local, { installMode: "ON_NEXT_RESTART" });
// 4) Optionally force a reload yourself (e.g. after the user taps "Update now").
await restartApp(/* onlyIfUpdateIsPending */ true);
}
// Suppress restarts during a critical flow (checkout, video call, β¦), then re-enable.
disallowRestart();
// β¦ later β¦
allowRestart();If you do not use
sync(), you must callnotifyAppReady()yourself once the app has booted successfully. Otherwise the SDK treats the new bundle as unverified and rolls it back on the next launch.
| Function | Purpose |
|---|---|
sync(options?, onProgress?) |
End-to-end: confirm β check β download β install. Returns a SyncStatus; never throws. |
checkForUpdate() |
Returns { action: "up-to-date" | "ota-update" | "embedded-revert", remotePackage? }. |
downloadUpdate(remotePackage, onProgress?) |
Downloads (patch or full bundle) and returns a LocalPackage. |
installUpdate(target, options?) |
Stages/applies a downloaded package using an installMode. |
notifyAppReady() |
Confirms the running bundle as good (rollback protection). |
getRunningBundleUpdateMetadata() |
Returns { label, packageHash, releaseNotes } for the running OTA bundle, or null for the embedded bundle. |
restartApp(onlyIfUpdateIsPending?) |
Reloads the JS bundle to apply a pending update. |
disallowRestart() / allowRestart() |
Block / unblock SDK-triggered restarts during critical flows. |
If you skipped cmpatch init in Part 3, run it now from your React Native project root; every question also has a flag for scripted setups:
cmpatch init
# scripted
cmpatch init \
--server-url https://updates.example.com \
--ios-app MyApp-iOS \
--android-app MyApp-Android \
--deployment Staging \
--yesInspect the resolved context:
cmpatch contextDry-run, then publish:
# Preview without uploading
cmpatch release-react --platform ios --deployment Staging --dry-run
cmpatch release-react --platform android --deployment Staging --dry-run
# Publish
cmpatch release-react --platform ios --deployment Staging --release-notes "Fix onboarding crash" --yes
cmpatch release-react --platform android --deployment Staging --release-notes "Fix onboarding crash" --yesrelease-react analyzes the project, auto-detects the bundler (Metro or Expo), computes the target binary version + native fingerprint, builds the bundle, and uploads it. If auto-detection can't determine a value, pass it explicitly:
cmpatch release-react \
--platform ios \
--deployment Staging \
--target-binary-version 1.2.3 \
--bundler metro \
--entry-file index.js \
--yescmpatch release-react and cmpatch release create opt into blocking when that
fingerprint disagrees with the fingerprint already recorded for the target
binary version. In an interactive run without --yes, review the complete
stored and release fingerprints and confirm to retry. Automation fails with a
clear hint; use --allow-fingerprint-mismatch only after verifying native
compatibility. Older clients and direct API requests that omit the opt-in field
retain the previous warning-only behavior.
Watch processing complete:
cmpatch release list --app MyApp-iOS --deployment Staging --format table
cmpatch release inspect --app MyApp-iOS --deployment Staging --label v1 --waitπ‘
cmpatch bundle --platform iosbuilds a.cmpatchartifact without uploading β useful for inspecting or publishing later viacmpatch release create --bundle-path file.cmpatch.When building a.cmpatchartifact with code signing enabled, the--private-key-pathparameter must be provided. For example:cmpatch bundle --private-key-path <path-to-key>.
The examples below run from a project root where
cmpatch inithas writtencodemagic-patch.config.json. The team never needs to be named: every command resolves the server's single default team automatically.
Gradual rollout
cmpatch release-react --platform ios --deployment Production \
--rollout-percentage 10 --release-notes "Gradual rollout" --yesMandatory update
cmpatch release-react --platform ios --deployment Production --mandatory --yesDisable / re-enable a release
cmpatch release disable --app MyApp-iOS --deployment Production --label v3 --yes
cmpatch release enable --app MyApp-iOS --deployment Production --label v3 --yesRoll back to the previous release
cmpatch release rollback --app MyApp-iOS --deployment Production --yesPromote a tested release from Staging to Production
cmpatch release promote \
--app MyApp-iOS \
--source-deployment Staging \
--dest-deployment Production \
--label v4 \
--yesMetrics
cmpatch deployment metrics --app MyApp-iOS --deployment Production --format table
cmpatch release metrics --app MyApp-iOS --deployment Production --label v4 --format tableThe client posts Downloaded / Installed / Success / Failed / Active events to <apiUrl>/v1/metrics/events. Metrics failures never block the update flow β the SDK queues and retries them natively.
Require signed releases at app creation, or enable it later:
cmpatch app create --name MySignedApp-iOS --require-code-signing
cmpatch app setting --app MyApp-iOS --require-code-signing=trueCreate private and public keys using OpenSSL:
# generate private RSA key and write it to patch-private-key.pem file
openssl genrsa -out patch-private-key.pem 2048
# export public key from patch-private-key.pem into patch-public-key.pem
openssl rsa -in patch-private-key.pem -pubout -out patch-public-key.pemYou can then expose the public key by running cat patch-public-key.pem. Copy the output (including the -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY----- lines) and add it to Info.plist as the CodemagicPatchPublicKey value for iOS and to strings.xml as the CodemagicPatchPublicKey for Android.
When publishing a signed app, sign the package-hash JWT with your private key:
cmpatch release-react --platform ios --deployment Staging \
--private-key-path ./patch-private-key.pem --yesTo enforce verification on-device, embed the matching public key (CodemagicPatchPublicKey). In Expo plugin config:
{
"deploymentKey": "ios-staging-deployment-key",
"downloadBaseUrl": "https://storage-updates.example.com/codemagic-patch",
"apiUrl": "https://updates.example.com",
"publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
}With a publicKey configured, the client rejects any release whose manifest signature is missing or doesn't match.
From your own machine, cmpatch runs the maintenance scripts over SSH β connecting, selecting, and confirming for you:
cmpatch selfhost backup --download # copy the backup to this machine too
cmpatch selfhost restore # lists the backups on the server, or uploads one from here
cmpatch selfhost upgrade # backs up, updates server + Caddy images, smoke-testsThe commands below are the same scripts run on the host. All of them run against the codemagic-patch-selfhost Compose project.
Status & logs (compose commands run by hand take the overlays matching the
SELFHOST_*_MODE flags in .env.selfhost; below is the default bundled stack)
docker compose --project-name codemagic-patch-selfhost --env-file .env.selfhost \
-f docker-compose.selfhost.yml -f deploy/selfhost/compose.bundled-db.yml \
-f deploy/selfhost/compose.bundled-storage.yml ps
docker compose --project-name codemagic-patch-selfhost --env-file .env.selfhost \
-f docker-compose.selfhost.yml -f deploy/selfhost/compose.bundled-db.yml \
-f deploy/selfhost/compose.bundled-storage.yml logs -f serverBackup (quiesces the server when at least one data component is bundled,
dumps bundled Postgres, and mirrors bundled MinIO; with both components external
it keeps the server running and creates a configuration-only backup β see
docs/self-hosting-compose.md)
scripts/selfhost/backup.sh
# β backups/codemagic-patch-selfhost-<timestamp>/ : env.selfhost, backup-manifest,
# versions.txt, postgres.dump + minio-codemagic-patch.tar.gz (bundled modes)Restore
scripts/selfhost/restore.sh backups/codemagic-patch-selfhost-<timestamp>
# also replace .env.selfhost from the backup:
scripts/selfhost/restore.sh --restore-env backups/codemagic-patch-selfhost-<timestamp>Restore always takes a pre-restore safety backup first (unless --skip-safety-backup).
Upgrade (backs up, updates server + Caddy images, then smoke-tests)
scripts/selfhost/upgrade.sh
# pin a specific server image:
scripts/selfhost/upgrade.sh --image registry.example.com/codemagic-patch-server:tagServer won't boot / OAuth errors
GITHUB_OAUTH_CLIENT_IDandGITHUB_OAUTH_CLIENT_SECRETare set.OAUTH_DEVICE_POLL_TOKEN_SECRETandWORKER_SHARED_SECRETare each β₯ 32 chars.- Under
REGISTRATION_MODE=invite_only,INITIAL_ADMIN_EMAILSis non-empty.
First admin sign-in rejected
INITIAL_ADMIN_EMAILSmatches the GitHub account's verified primary email.- The OAuth App callback URL is
https://<api-domain>/auth/callback.
Caddy certificate issuance is slow
- API/storage DNS records point at the host; ports 80/443 are open.
- With Cloudflare, keep the storage domain DNS-only until the first certificate is issued.
Release published but the app finds no update
- The app's embedded
CodemagicPatchDeploymentKeymatches the key fromcmpatch deployment list. - The app's binary version matches the release's target binary version.
CodemagicPatchDownloadBaseUrlends with/codemagic-patch.- iOS and Android use separate deployment keys.
Release stuck processing
cmpatch release inspect --app MyApp-iOS --deployment Staging --label <label> --wait
docker compose --project-name codemagic-patch-selfhost --env-file .env.selfhost \
-f docker-compose.selfhost.yml -f deploy/selfhost/compose.bundled-db.yml \
-f deploy/selfhost/compose.bundled-storage.yml logs --tail=200 serverCheck local readiness before publishing
cmpatch doctor --app MyApp-iOS --deployment Staging --verboseThe SDK reads these objects under your Download base URL:
<downloadBaseUrl>/<deploymentKey>/meta.json
<downloadBaseUrl>/<deploymentKey>/<binaryVersion>/manifest.json
<downloadBaseUrl>/<deploymentKey>/<binaryVersion>/<runningPackageHash>/manifest.json
- The manifest carries the full bundle URL and, when available, a binary patch URL. The SDK prefers the smaller patch and automatically falls back to the full bundle if the patch download or apply fails.
- Bundle file names: iOS
main.jsbundle, Androidindex.android.bundle. - The MinIO bucket (
codemagic-patch) allows public reads of published artifacts but denies public reads under the_internal/*prefix (staged uploads).
.env.selfhost is generated by scripts/selfhost/install.sh. See .env.selfhost.example for the fully annotated list. Edit it by hand only if you're not using the installer, then restart the stack with the same --project-name.
Required
| Variable | Description |
|---|---|
CODEMAGIC_PATCH_API_DOMAIN |
API/dashboard domain (no scheme/path) |
CODEMAGIC_PATCH_STORAGE_DOMAIN |
Storage domain (bundled storage only; must differ from the API domain) |
ACME_EMAIL |
Email for Let's Encrypt certificates |
SERVER_URL |
Public API URL, e.g. https://updates.example.com |
PUBLIC_BASE_URL |
Public artifact base β https://<storage-domain>/codemagic-patch with bundled storage, operator-set (the bucket or CDN URL) with external storage |
POSTGRES_DB / _USER / _PASSWORD |
PostgreSQL credentials (bundled database only) |
DATABASE_URL |
External PostgreSQL URL (SELFHOST_DATABASE_MODE=external only) |
MINIO_ROOT_USER / _PASSWORD |
MinIO credentials (bundled storage only) |
S3_BUCKET |
External S3 bucket (SELFHOST_STORAGE_MODE=s3 only; optional alongside it: S3_REGION, S3_ENDPOINT, S3_FORCE_PATH_STYLE, S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY) |
GCS_PUBLIC_BUCKET / GCS_INTERNAL_BUCKET |
External GCS buckets (SELFHOST_STORAGE_MODE=gcs only; must differ) |
WORKER_SHARED_SECRET |
Protects worker routes (β₯ 32 chars) |
GITHUB_OAUTH_CLIENT_ID |
GitHub OAuth App client ID |
GITHUB_OAUTH_CLIENT_SECRET |
GitHub OAuth App client secret |
OAUTH_DEVICE_POLL_TOKEN_SECRET |
Local random secret (β₯ 32 chars) |
INITIAL_ADMIN_EMAILS |
Allowlist for the first invite-only admin sign-in |
The server refuses to boot while
WORKER_SHARED_SECRETorOAUTH_DEVICE_POLL_TOKEN_SECRETare shorter than 32 chars, or if GitHub OAuth is unset β so a verbatim copy of the example file fails fast instead of running with known secrets.
Common optional
| Variable | Default | Description |
|---|---|---|
MODE |
all |
all Β· api Β· worker |
SELFHOST_DATABASE_MODE |
bundled |
bundled or external β selects the database compose overlay; fixed at install time |
SELFHOST_STORAGE_MODE |
bundled |
bundled Β· s3 Β· gcs β selects the storage compose overlay; fixed at install time |
SELFHOST_STORAGE_ORIGIN_MODE |
direct |
direct or bundled-CloudFront cdn-origin |
REGISTRATION_MODE |
invite_only |
invite_only or open |
STORAGE_ADAPTER |
s3 (self-host) |
s3 Β· gcs Β· memory |
DELIVERY_ADAPTER |
base-url |
base-url, cloudflare, or cloudfront |
CLOUDFLARE_API_TOKEN |
β | Token scoped to Zone β Cache Purge (required with cloudflare) |
CLOUDFLARE_ZONE_ID |
β | Zone containing the storage domain (required with cloudflare) |
CLOUDFLARE_API_BASE_URL |
https://api.cloudflare.com/client/v4 |
Cloudflare API endpoint override |
CLOUDFRONT_DISTRIBUTION_ID |
β | Required with cloudfront; access key pair is optional when using an IAM role |
MANIFEST_CACHE_CONTROL |
derived from DELIVERY_ADAPTER |
Cache-Control for manifests: no-cache, must-revalidate on base-url, public, max-age=0, s-maxage=300, must-revalidate behind a purging CDN |
MAX_UPLOAD_SIZE |
200mb |
Max artifact upload size |
RUN_MIGRATIONS |
true |
Run DB migrations on boot |
LOGGER |
true |
Set false to silence server logs |
Run cmpatch help for grouped topics, or cmpatch <command> --help for full flags. Both cmpatch and codemagic-patch invoke the same binary.
Auth & config
| Command | Description |
|---|---|
cmpatch login / logout / whoami |
Browser or token sign-in / out / identity |
cmpatch token create | list | revoke |
Manage personal access tokens (cm_pat_β¦) |
cmpatch config list | get | set | unset |
Store defaults such as server-url (the team is resolved automatically) |
cmpatch init |
Link a project; interactively install a server or enter its URL, sign in after a new install, then create or select each platform app |
cmpatch context |
Show the effective resolved context |
Self-hosting
| Command | Description |
|---|---|
cmpatch selfhost install [user@vps] |
Prepare a server and run the guided bundled Postgres/MinIO install |
cmpatch selfhost upgrade [user@vps] |
Fast-forward and run the guarded backup-and-update workflow |
cmpatch selfhost backup [user@vps] |
Take a mode-aware backup (--download copies it locally) |
cmpatch selfhost restore [backup] [user@vps] |
Take a safety backup and restore the selected backup |
Apps & deployments
| Command | Description |
|---|---|
cmpatch app create | list | show | rename | remove | setting |
Manage apps (and code-signing) |
cmpatch deployment create | list | rename | remove | clear |
Manage deployments |
cmpatch deployment history | metrics |
Release history / aggregate metrics |
Releases
| Command | Description |
|---|---|
cmpatch release-react |
Build and publish from an RN project |
cmpatch bundle |
Build a .cmpatch artifact without uploading |
cmpatch release create |
Publish a pre-built bundle / .cmpatch |
cmpatch release list | show | inspect |
Browse releases; inspect --wait to poll |
cmpatch release patch | enable | disable |
Edit metadata / toggle availability |
cmpatch release promote |
Copy a release to another deployment |
cmpatch release rollback |
Revert to the previous release |
cmpatch release metrics |
Metrics for one release |
Members & diagnostics
| Command | Description |
|---|---|
cmpatch member add | invite | provision | list | remove β¦ |
Team membership and invitations |
cmpatch doctor |
Check local readiness before publishing |
cmpatch fingerprint --platform ios|android |
Compute the native fingerprint |
List/metrics commands accept --format table|json.
| Path | Package | Description |
|---|---|---|
server/ |
@codemagic/patch-server |
Fastify API + release/manifest worker |
client/ |
@codemagic/react-native-patch |
React Native SDK + Expo config plugin (app.plugin.js) |
cli/ |
codemagic-patch |
The cmpatch CLI |
web-dashboard/ |
web-dashboard |
React SPA dashboard (served by Caddy) |
shared/ |
@codemagic/patch-shared |
Types and helpers shared across packages |
deploy/selfhost/ |
β | Caddyfile, MinIO bucket policy, dashboard image build |
scripts/selfhost/ |
β | install.sh, backup.sh, restore.sh, upgrade.sh, smoke.sh |
scripts/local-eval/ |
β | Local evaluation stack bootstrap (up.sh) and its smoke checks |
examples/ |
β | Evaluation-stack seed data, bundle fixtures, and the on-device demo app |
docs/ |
β | Guides β e.g. migrating from CodePush |
Your feedback helps us improve Codemagic Patch. We'd love to hear what's working well, what's been challenging, and what features or improvements you'd like to see.
You can share your thoughts in any of the following ways:
π¬ Start a discussion in GitHub Discussions π Book a feedback call π§ Send an email to: zach@codemagic.io
We appreciate your time and look forward to hearing from you!