Skip to content

Latest commit

Β 

History

87 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Codemagic Patch

Discord Β  npm Β  npm downloads Β  GitHub Stars Β  Follow @codemagicio Β  LinkedIn

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.

Quickstart β€” try it locally

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-eval

Or from a clone of this repository:

git clone https://github.com/codemagic-ci-cd/codemagic-patch.git
cd codemagic-patch
./scripts/local-eval/up.sh

Either 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-url guidance otherwise
  • cmpatch selfhost local-eval status shows 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).


Table of contents

Getting started

  1. Quickstart β€” try it locally
  2. How it works
  3. Core concepts
  4. Requirements
  5. Support

Production setup

  1. Part 1 β€” Run the server (self-host)
  2. Part 2 β€” Install the CLI and sign in
  3. Part 3 β€” Create apps & deployments
  4. Part 4 β€” Connect your React Native app
  5. Part 5 β€” Publish your first release

Release management & operations

  1. Managing releases
  2. Code signing (optional)
  3. Operations
  4. Troubleshooting

Reference

  1. How delivery works
  2. Configuration reference
  3. CLI command reference
  4. Repository layout

Guides

  1. Migrating from CodePush

How it works

  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  β”‚
                                   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  1. 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.
  2. 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.
  3. 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

Core concepts

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.


Requirements

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
  • A GitHub OAuth App (see below)

Using the CLI

  • Node.js 20.19+ or 22.12+
  • Building from this repo instead requires Node.js >=22.20.0 and Yarn 4.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 minSdkVersion 23+
  • 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.

Support

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.


Part 1 β€” Run the server (self-host)

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 install

The 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.

1.1 GitHub OAuth App

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.

1.2 CDN in front of storage (strongly recommended for production)

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.

1.3 Installing on the server instead

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.selfhost with 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 /health and storage health, and
  • prepares the single fixed default-team on 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.selfhost holds production secrets. Back it up and never commit or expose it.

1.4 Verify

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.sh

1.5 Finish the Cloudflare setup

The 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.

1. Switch the DNS record to proxied

  1. 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.
  2. Switch the storage record to Proxied (orange cloud).
  3. 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.

2. Add a Cache Rule

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.

3. Verify

# 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-status

After 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.

Enabling Cloudflare on an existing install

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.


Part 2 β€” Install the CLI and sign in

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-cli

Alternatively, 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 globally

Sign in as the admin (GitHub sign-in and approval complete in the browser):

cmpatch login --server-url https://updates.example.com

cmpatch 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 ci

The 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 by cmpatch login (stored in ~/.codemagic-patch/).


Part 3 β€” Create apps & deployments

From your React Native project root, let the CLI do it:

cmpatch init

It 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-Android

Either 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 table

Part 4 β€” Connect your React Native app

Migrating from CodePush? The migration guide maps react-native-code-push native config, JS APIs, and code-push CLI commands to their Codemagic Patch equivalents.

Add the SDK:

yarn add @codemagic/react-native-patch

The 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.

Option A β€” Bare React Native

Wire the config and native bundle selection manually.

iOS

  • Install the native pod:

    cd ios && pod install && cd ..
  • Add CodemagicPatchDeploymentKey, CodemagicPatchDownloadBaseUrl, CodemagicPatchApiUrl to ios/<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 DEBUG branch 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, override sourceURLForBridge: with the same selection β€” see client/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), override getJSBundleFile() inside the host object:
    import io.codemagic.patch.CodemagicPatch
    // ...
    override val reactNativeHost: ReactNativeHost =
        object : DefaultReactNativeHost(this) {
          // ...existing overrides...
          override fun getJSBundleFile(): String? =
              CodemagicPatch.getJSBundleFile(applicationContext)
        }
    On RN β‰₯ 0.82 (reactHost via getDefaultReactHost), pass it as jsBundleFilePath:
    import io.codemagic.patch.CodemagicPatch
    // ...
    override val reactHost: ReactHost by lazy {
      getDefaultReactHost(
        context = applicationContext,
        packageList = PackageList(this).packages,
        jsBundleFilePath = CodemagicPatch.getJSBundleFile(applicationContext),
      )
    }
    Reference: 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).

Option B β€” Expo (prebuild)

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. The DEBUG / Metro branch is left untouched, so local development keeps working.
  • Android MainApplication β†’ prefers CodemagicPatch.getJSBundleFile(applicationContext) (both the RN ≀ 0.81 getJSBundleFile() and RN β‰₯ 0.82 jsBundleFilePath host shapes are handled).

Run updates in app code

What sync() does

sync() is the one call most apps need. On each invocation it runs the whole update flow in order:

  1. notifyAppReady() β€” marks the currently running bundle as healthy. This is the SDK's rollback protection: if a freshly installed bundle crashes before sync() (and therefore notifyAppReady()) runs, the next launch automatically reverts to the last known-good bundle. Because sync() calls it first, simply running sync() on every startup confirms the previous update and arms rollback for the next one β€” you don't have to call it yourself.
  2. Check the server for an update matching this app's deployment key + binary version.
  3. Download the new bundle (or a smaller binary patch, with automatic fallback to the full bundle).
  4. 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.

Step 1 β€” Minimal integration (drop-in)

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.

Step 2 β€” Choose how updates apply (install modes)

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 to AppState:

import { AppState } from "react-native";
import { sync } from "@codemagic/react-native-patch";

AppState.addEventListener("change", (next) => {
  if (next === "active") void sync();
});

Step 3 β€” React to the result and show progress

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 };
}

Step 4 β€” Manual control (advanced)

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 call notifyAppReady() yourself once the app has booted successfully. Otherwise the SDK treats the new bundle as unverified and rolls it back on the next launch.

API summary

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.

Part 5 β€” Publish your first release

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 \
  --yes

Inspect the resolved context:

cmpatch context

Dry-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" --yes

release-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 \
  --yes

cmpatch 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 ios builds a .cmpatch artifact without uploading β€” useful for inspecting or publishing later via cmpatch release create --bundle-path file.cmpatch.When building a .cmpatch artifact with code signing enabled, the --private-key-path parameter must be provided. For example: cmpatch bundle --private-key-path <path-to-key>.


Managing releases

The examples below run from a project root where cmpatch init has written codemagic-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" --yes

Mandatory update

cmpatch release-react --platform ios --deployment Production --mandatory --yes

Disable / 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 --yes

Roll back to the previous release

cmpatch release rollback --app MyApp-iOS --deployment Production --yes

Promote a tested release from Staging to Production

cmpatch release promote \
  --app MyApp-iOS \
  --source-deployment Staging \
  --dest-deployment Production \
  --label v4 \
  --yes

Metrics

cmpatch deployment metrics --app MyApp-iOS --deployment Production --format table
cmpatch release metrics    --app MyApp-iOS --deployment Production --label v4 --format table

The 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.


Code signing (optional)

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=true

Create 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.pem

You 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 --yes

To 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.


Operations

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-tests

The 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 server

Backup (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:tag

Troubleshooting

Server won't boot / OAuth errors

  • GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET are set.
  • OAUTH_DEVICE_POLL_TOKEN_SECRET and WORKER_SHARED_SECRET are each β‰₯ 32 chars.
  • Under REGISTRATION_MODE=invite_only, INITIAL_ADMIN_EMAILS is non-empty.

First admin sign-in rejected

  • INITIAL_ADMIN_EMAILS matches 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 CodemagicPatchDeploymentKey matches the key from cmpatch deployment list.
  • The app's binary version matches the release's target binary version.
  • CodemagicPatchDownloadBaseUrl ends 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 server

Check local readiness before publishing

cmpatch doctor --app MyApp-iOS --deployment Staging --verbose

How delivery works

The 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, Android index.android.bundle.
  • The MinIO bucket (codemagic-patch) allows public reads of published artifacts but denies public reads under the _internal/* prefix (staged uploads).

Configuration reference

.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_SECRET or OAUTH_DEVICE_POLL_TOKEN_SECRET are 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

CLI command reference

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.


Repository layout

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

Feedback

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!

About

Self-hosted over-the-air update service for React Native apps

Topics

Resources

Stars

117 stars

Watchers

2 watching

Forks

Releases

Contributors

Languages