Just evaluating? Don't start here. The local evaluation stack runs the full product — dashboard included — on a laptop with one command (
cmpatch selfhost local-evalafter installing the npm CLI). It uses Node.js, git, curl, and Docker with Compose v2; the CLI offers to install or start Docker when needed.scripts/local-eval/up.shremains available from a clone. Deploying for real (this document) additionally needs public DNS, open ports 80/443, and an OAuth app on GitHub, Bitbucket Cloud, and/or GitLab.
Installing from your own machine?
cmpatch selfhost installruns this document's installer over SSH and walks you through every step; follow the Install guide instead. This document is the reference for running the scripts on the server yourself, and for setups the wizard does not cover: an external PostgreSQL database, several OAuth providers at once, and plain HTTP.
This is the supported deployment path for Codemagic Patch — and in the initial
open-source release, the only one. It runs the Codemagic Patch server as a single
process in MODE=all on one Docker host with Caddy for HTTPS. By default the
stack bundles PostgreSQL and Silo; either can instead be a service you
operate — your own PostgreSQL, and object storage on S3 or GCS — see
External database and external storage.
Split deployments with separate API and worker services are a work in
progress.
Related material:
docs/custom-deployment.mddescribes what the server requires if you bring your own platform. It is reference material, not a supported deployment path in the initial open-source release.- For evaluation on a workstation, use the local quickstart instead of deploying.
cmpatch selfhost install defaults to bundled Silo. You can choose Cloudflare
R2, Amazon S3, or Google Cloud Storage instead. The installer can create those
buckets or walk you through the console. Direct install.sh flags and bucket
layout are in
External database and external storage.
Prepare everything below before running the installer. The pieces span two roles: the server host that runs the stack, and the release workstation you publish updates from. They can be the same machine, but their toolchains differ.
- A publicly reachable Linux host with a public IP — not behind NAT. Caddy obtains Let's Encrypt certificates over HTTP, so the host must be reachable from the internet on ports 80/443. A laptop behind NAT or a home router will fail at the certificate step. (A host that cannot meet this — an intranet, a tailnet, a laptop — can run the stack over plain HTTP instead; see Plain HTTP (no TLS). That mode has none of the DNS, certificate, or port 443 requirements below.)
- Sizing: at runtime the stack (the server, Caddy, and — with the default bundled modes — PostgreSQL and Silo in one Compose project) fits in roughly 2 GB RAM plus a few GB of disk that grows with the OTA artifacts you store. The memory peak is the first install, which builds the server and dashboard images on the host. The build itself fits within 2 GiB (measured peak ~1.6 GiB with both images building in parallel), but the OS and Docker daemon share that same memory, so headroom on a 2 GB host is thin — if the build is OOM-killed, add a few GB of swap or use a larger host, then rerun the installer.
- Docker Engine with Docker Compose v2.
- TCP ports 80 and 443 open to the internet (host firewall / cloud security group).
- Command-line tools
dockerandcurl(used by the installer), plusgitto clone this repository. - Outbound network access from the host to the services below (these are
services, not an exact firewall allowlist — only GitHub publishes stable
hostnames):
- Let's Encrypt (ACME) — TLS certificates.
- GitHub (
github.com/api.github.com) — OAuth token exchange when GitHub sign-in is configured, the unauthenticated lookup used by handle invites, and the release lookup behind the dashboard's version card (SERVER_UPDATE_CHECK=falsein.env.selfhostturns that one off). - The usage report collector (
telemetry.codemagicpatch.dev) — the daily anonymous usage report: aggregate counts only, no identifiers, used only for public statistics and never to enforce the licence's MAU limit. Off withSERVER_TELEMETRY=falseorDO_NOT_TRACK=1in.env.selfhost, orinstall.sh --no-telemetry. - Bitbucket (
bitbucket.org/api.bitbucket.org) — OAuth token exchange when Bitbucket sign-in is configured. - GitLab (
gitlab.com, or your self-hosted GitLab origin) — OAuth token exchange and identity lookup when GitLab sign-in is configured. - A container registry (Docker Hub) — the PostgreSQL and Silo images, plus the image build base layers.
- The Cloudflare API — only if you enable the CDN.
When using cmpatch selfhost install, the DNS setup step
can create these records and wait for them before starting the remote installer.
The prerequisite below applies directly when running the shell installer yourself.
Point two records at the host and let them propagate before running the installer: Caddy's certificate challenge needs them live, or the install stalls at the HTTPS-readiness step.
updates.example.com A/AAAA <server-ip>
storage-updates.example.com A/AAAA <server-ip>
The storage domain is intentionally separate. The API domain serves the control
plane, while the storage domain serves OTA artifacts from Silo under
/codemagic-patch. (If you plan to front storage with Cloudflare, keep the storage
record DNS-only during install and switch it to proxied afterward — see
Optional: front storage with Cloudflare CDN.)
The storage record applies to the default bundled storage only. With
external object storage (--storage-mode s3/gcs) there is no storage domain
— devices download from the bucket, or the CDN in front of it — so only the
API record is needed; see
External database and external storage.
The server refuses to boot without at least one OAuth sign-in provider —
GitHub, Bitbucket Cloud, or GitLab. The SSH wizard guides you through creating
the app. For direct script installs, prepare its client ID and client
secret first — see Required: OAuth sign-in,
Bitbucket sign-in, or GitLab sign-in. You sign in as the first admin
with the provider account whose verified primary email you pass as
--email, so decide that account up front.
Publishing updates uses the cmpatch CLI, which you build from this repo with
Node.js 22.20+ and Yarn 4 (via Corepack) — see
Install the CLI. This can be the server host or any dev
machine; the server-host install itself does not need Node.
To serve artifacts through Cloudflare with automatic cache purge on release, also
prepare a Cloudflare API token scoped to Zone → Cache Purge and the
Zone ID of the zone holding your storage domain (with external storage:
the PUBLIC_BASE_URL host) — see
Optional: front storage with Cloudflare CDN.
From your own machine, cmpatch selfhost install user@vps runs this same
installer over SSH and guides you through DNS, the OAuth app, and an optional
CDN, checking each step; it covers the default bundled Postgres/Silo stack
(see Maintaining the server from your own machine
for the day-2 commands). It does not take --allow-http: the
plain-HTTP shape serves the machine the stack runs on
alone, which is not what an install over ssh produces. The rest of this
section is the on-server path.
If you are not already on the server, one command gets you there and starts the installer:
ssh -t user@vps 'git clone https://github.com/codemagic-ci-cd/codemagic-patch && cd codemagic-patch && scripts/selfhost/install.sh'Use the direct script for external PostgreSQL or multiple OAuth providers. The SSH wizard supports external R2/S3/GCS storage and a choice of GitHub, Bitbucket Cloud, or GitLab (gitlab.com or self-managed) for sign-in.
From a checkout already on the server:
./scripts/selfhost/install.shNon-interactive form:
./scripts/selfhost/install.sh \
--api-domain updates.example.com \
--storage-domain storage-updates.example.com \
--email admin@example.com \
--github-oauth-client-id <client-id> \
--github-oauth-client-secret <client-secret>The installer creates .env.selfhost, builds the server and caddy (web
dashboard) images, starts the Compose stack, and waits for HTTPS readiness.
At least one OAuth sign-in provider is required — GitHub, Bitbucket, and/or
GitLab. Create the GitHub OAuth App, Bitbucket OAuth consumer, or GitLab
application first (next sections)
and pass its client ID and client secret; the server refuses to boot without
one. For a Bitbucket-only install, pass --bitbucket-oauth-client-id and
--bitbucket-oauth-client-secret — the installer then skips the GitHub
prompts (see Bitbucket sign-in). The installer mints
no API token; the first admin signs in with a configured provider and
creates everything from there. Store .env.selfhost securely.
People sign in through an OAuth provider: users open the web dashboard or run
cmpatch login, authorize on GitHub, Bitbucket, or GitLab, and get their own
account — there is no shared token to copy around. At least one provider must be
configured. This section walks through the GitHub OAuth App;
Bitbucket sign-in and GitLab sign-in
cover the other providers, alone or alongside GitHub. (Machine/CI access still uses personal access
tokens; see Machine & CI access.)
One OAuth App (or consumer) serves both sign-in paths:
- The web dashboard uses the web authorization flow, which needs an
Authorization callback URL (
https://<api-domain>/auth/callback) and a client secret generated on the OAuth App. cmpatch loginopens that same dashboard sign-in in the browser and finishes over a localhost redirect, so it needs no extra OAuth App settings.
- Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App (use an organization's developer settings if the app should belong to a team).
- Application name: anything, for example
Codemagic Patch (self-host). - Homepage URL: your API domain, for example
https://updates.example.com. - Authorization callback URL:
https://<api-domain>/auth/callback, for examplehttps://updates.example.com/auth/callback— the web dashboard redirects here after the user authorizes on GitHub. - Click Register application.
- On the app page, copy the Client ID, then click Generate a new client secret and copy the client secret.
./scripts/selfhost/install.sh \
--api-domain updates.example.com \
--storage-domain storage-updates.example.com \
--email admin@example.com \
--github-oauth-client-id <client-id> \
--github-oauth-client-secret <client-secret>The interactive installer prompts for the GitHub Client ID and client secret
when they are not passed — unless Bitbucket or GitLab OAuth is configured instead, in
which case the GitHub prompts are skipped (see
Bitbucket sign-in or GitLab sign-in). The installer generates
OAUTH_CLI_AUTH_SECRET and writes these into .env.selfhost (the GITHUB_*
variables only when GitHub is configured):
| Variable | Purpose |
|---|---|
GITHUB_OAUTH_CLIENT_ID |
The OAuth App Client ID you copied. |
GITHUB_OAUTH_CLIENT_SECRET |
The OAuth App client secret. Used server-side for the web dashboard's code exchange. |
OAUTH_CLI_AUTH_SECRET |
Local random secret the server uses to sign the CLI browser-login authorization codes (auto-generated). |
GITHUB_OAUTH_SCOPES |
GitHub scopes requested at sign-in. Defaults to read:user user:email (used to read the user's identity and verified email). Override with --github-oauth-scopes. |
GITHUB_OAUTH_ALLOWED_REDIRECT_URIS |
Optional exact-match allowlist for the web flow's redirect URI. Set to https://<api-domain>/auth/callback. |
INITIAL_ADMIN_EMAILS |
Set to your --email. Lets that email create the admin account on first sign-in under invite-only registration. |
Normal installer reruns preserve existing OAuth settings. To correct credentials,
run cmpatch selfhost install --repair. For manual configuration, edit
.env.selfhost and recreate the stack (see Upgrade). Setting
a provider client id (GITHUB_OAUTH_CLIENT_ID, BITBUCKET_OAUTH_CLIENT_ID, or GITLAB_OAUTH_CLIENT_ID)
by hand also requires setting OAUTH_CLI_AUTH_SECRET (32+ chars), or the
server refuses to start.
The installer mints no bootstrap API token. The first admin is created when
the email in INITIAL_ADMIN_EMAILS signs in with a configured provider. See
First admin & inviting members.
In the browser, users sign in at the dashboard — see Web Dashboard. From the terminal:
cmpatch login --server-url https://updates.example.comThe CLI opens the dashboard sign-in in the default browser (printing the URL
as a fallback — --no-browser prints it without opening anything). The user
signs in with any configured provider, approves the "Sign in the CLI?" screen,
and the browser hands a short-lived, PKCE-bound sign-in code back to the CLI
over a 127.0.0.1 redirect. The CLI then stores the session in
~/.codemagic-patch/credentials.json. An invited user gets an account
created automatically on first sign-in. The provider account's email must be
verified — sign-in fails otherwise. On machines without a browser
(SSH, CI), use a personal access token instead: cmpatch login --token
(see Machine & CI access).
Access control: registration is invite-only by default. A sign-in only creates a new account if that email is in
INITIAL_ADMIN_EMAILSor has a pending team invitation (or already has an account, which links on first sign-in). Uninvited users get a 403. SetREGISTRATION_MODE=opento instead let any user with a verified provider email self-register. This gate applies only to self-service sign-in; admins always add people withmember invite/member provision.
Bitbucket Cloud can be configured as a sign-in provider next to GitHub,
GitLab, or as the only provider. The dashboard login page shows one button per
configured provider ("Continue with
GitHub" / "Continue with Bitbucket" / "Continue with GitLab"), and each identity resolves to the same
kind of account — invitations, admin emails, and RBAC work identically.
cmpatch login signs in through the same dashboard page in the browser, so
Bitbucket users get the CLI signed in the same way — the provider choice
happens on the login page, not in the CLI.
- Go to the Bitbucket workspace → Settings → OAuth consumers → Add consumer.
- Name: anything, for example
Codemagic Patch (self-host). - Callback URL:
https://<api-domain>/auth/callback, for examplehttps://updates.example.com/auth/callback. - Permissions: check Account: Read and Email — Bitbucket scopes live on the consumer, not in the authorize request, so nothing else is configured server-side.
- Save and copy the consumer's Key and Secret.
One consumer per dashboard origin: Bitbucket matches callback URLs by prefix within the single configured URL, so a consumer cannot serve multiple dashboard origins. Run a second dashboard origin → create a second consumer.
On a fresh install, pass the consumer credentials to the installer:
./scripts/selfhost/install.sh \
... \
--bitbucket-oauth-client-id <consumer-key> \
--bitbucket-oauth-client-secret <consumer-secret>Passing only the Bitbucket flags — no --github-oauth-* flags — runs a
Bitbucket-only install: the installer skips the interactive GitHub prompts
and writes no GITHUB_* variables, and the dashboard shows only the Bitbucket
button.
On an existing stack, add to .env.selfhost and recreate the stack (see
Upgrade):
BITBUCKET_OAUTH_CLIENT_ID=<consumer-key>
BITBUCKET_OAUTH_CLIENT_SECRET=<consumer-secret>
BITBUCKET_OAUTH_ALLOWED_REDIRECT_URIS=https://<api-domain>/auth/callbackBoth values are required together — the server refuses to boot with a client
id but no secret. Any web provider also requires the CLI auth secret the
installer already generates (OAUTH_CLI_AUTH_SECRET; older installs carry it
as OAUTH_DEVICE_POLL_TOKEN_SECRET, which the server accepts permanently) —
the server refuses to boot without one so cmpatch login can never dead-end
after the browser sign-in. The user's
Bitbucket primary email must be confirmed; sign-in fails otherwise with a
"confirm the primary email" error.
GitLab (gitlab.com or self-hosted) can be configured as a sign-in provider
next to GitHub/Bitbucket, or as a provider when they are omitted. The
dashboard login page shows one button per configured provider, and every
identity resolves to the same kind of account — invitations, admin emails,
and RBAC work identically. cmpatch login signs in through the same
dashboard page in the browser, so GitLab users get the CLI signed in the
same way — the provider choice happens on the login page, not in the CLI.
- Go to GitLab → User Settings → Applications (or the admin area for a shared application).
- Name: anything, for example
Codemagic Patch (self-host). - Redirect URI:
https://<api-domain>/auth/callback, for examplehttps://updates.example.com/auth/callback. - Scopes: check
read_user— nothing broader is needed. - Check Confidential and click Save application.
- Copy the Application ID and Secret.
On a fresh install, pass the application credentials to the installer:
./scripts/selfhost/install.sh \
... \
--gitlab-oauth-client-id <application-id> \
--gitlab-oauth-client-secret <secret>Passing only the GitLab flags — no --github-oauth-* or
--bitbucket-oauth-* flags — runs an install without GitHub/Bitbucket: the
installer skips the interactive GitHub prompts and writes no GITHUB_* /
BITBUCKET_* variables, and the dashboard shows only the GitLab button.
Self-hosted GitLab adds the origin:
./scripts/selfhost/install.sh \
... \
--gitlab-oauth-client-id <application-id> \
--gitlab-oauth-client-secret <secret> \
--gitlab-oauth-base-url https://gitlab.example.comOn an existing stack, add to .env.selfhost and recreate the stack (see
Upgrade):
GITLAB_OAUTH_CLIENT_ID=<application-id>
GITLAB_OAUTH_CLIENT_SECRET=<secret>
GITLAB_OAUTH_SCOPES=read_user
GITLAB_OAUTH_BASE_URL=https://gitlab.example.com # self-hosted only; omit for gitlab.com
GITLAB_OAUTH_ALLOWED_REDIRECT_URIS=https://<api-domain>/auth/callbackGITLAB_API_BASE_URL is an optional override and defaults to
<base>/api/v4. Both ID and secret are required together — the server
refuses to boot with a client id but no secret. The user's GitLab email
must be confirmed; sign-in fails otherwise with a "confirm the primary
email" error.
The SSH wizard supports both: choose GitLab during
cmpatch selfhost installand enter the instance address when asked (Enter keeps gitlab.com), or pass--gitlab-oauth-base-url. For multiple OAuth providers, usescripts/selfhost/install.shas shown above.
install.sh --allow-http runs the same stack — the same server image, Caddy,
the bundled PostgreSQL and Silo, OAuth sign-in — over plain HTTP on port
80 with no certificates, for the machine it runs on alone. It exists to
evaluate the real self-host stack on a laptop: the API site is localhost and
the bundled storage site is localhost:9110, so nothing in the
Requirements about a public IP, DNS records, Let's Encrypt,
open ports, or firewalls applies. That is also the boundary: a server other
machines reach — an intranet host, a VPS, anything with a hostname or an IP —
needs exactly that infrastructure, and with it the certificate the HTTPS
install obtains, so the installer refuses any name but localhost here rather
than half-supporting a deployment over plain HTTP.
./scripts/selfhost/install.sh --allow-http \
--email admin@example.com \
--github-oauth-client-id <client-id> \
--github-oauth-client-secret <client-secret>--api-domain and --storage-domain are optional with the flag and default
to localhost and localhost:9110; passing them may only repeat localhost,
or name another localhost:<port> for storage.
cmpatch selfhost install does not take the flag: it installs a server other
machines reach, over ssh, and refuses --allow-http with the two commands
that cover the local case — this script, run from a clone on the machine
itself, and cmpatch selfhost local-eval for a quick look that needs no
OAuth app.
What changes, compared with the default HTTPS install:
- Domains. The API domain is
localhost; the bundled storage domain islocalhost:<port>(default9110; not 80 or 443), because Caddy needs two distinct site addresses and on one machine a second port is the only way to get one. Any other hostname or IP is refused with the flag — andlocalhostis refused without it. With external storage (--storage-mode s3/gcs) anhttp://--public-base-urlis accepted only onlocalhosttoo; storage other machines reach is anhttps://bucket. - URLs.
SERVER_URL,PUBLIC_BASE_URL, and the OAuth redirect allowlist are written ashttp://localhost…. Register the OAuth app or consumer with the callback URLhttp://localhost/auth/callbackaccordingly. - Reach. Port 80 and the storage port are bound to the loopback
interface (
127.0.0.1) only, so connections can come from this machine alone: a browser on it, an iOS simulator, or any client that resolveslocalhostto it. Another machine on the network cannot connect, whateverHostheader it sends — Caddy'slocalhostsite address matches the request'sHostheader, not the address the connection arrived on, so the binding is the boundary, not the site address. Port 443 is not published. - The app. Point
CodemagicPatchApiUrlandCodemagicPatchDownloadBaseUrlat thehttp://URLs the installer prints, and allow cleartext traffic to them (Androidandroid:usesCleartextTrafficor a network security config; iOS App Transport Security exceptions). Traffic to this server is unencrypted: use it on a network you trust. - Compose.
.env.selfhostrecordsSELFHOST_SCHEME=http, which selects thedeploy/selfhost/compose.http.ymloverlay (see Mode flags and compose overlays). The overlay hands Caddy anhttp://site scheme and replaces the base file's port list (ports: !override) with127.0.0.1:80:80and the storage port recorded inSELFHOST_HTTP_STORAGE_PORT, also on127.0.0.1; 443 is not published, so it need not be free. The replacement matters: a plainports:list would merge with the base file's80:80and leave port 80 reachable from every interface. The same applies to adocker-compose.selfhost.override.yml, which is merged after this overlay: aports:entry there must bind to127.0.0.1(or use!override), and every selfhost script refuses to render or start an http stack whose merged configuration publishes a port on every interface.ACME_EMAILis still written (the admin email): Caddy'semailoption rejects an empty value even when no certificate is issued. - Not combinable with a CDN.
--cloudflareand--cloudfrontare refused together with--allow-http, and so is an env file edited to pairSELFHOST_SCHEME=httpwith a CDN adapter: both CDNs servehttps://viewer URLs and pull from an origin they can verify. - Fixed at install time, like the database and storage modes. Moving a
deployment between HTTPS and plain HTTP changes every URL its clients and
its OAuth provider were given, so it is a reinstall (remove
.env.selfhostand install again), not a rerun;--allow-httpon an existing HTTPS deployment is ignored with a warning, and--repair-envdoes not change it.--repair-env --storage-domain <host:port>does keepPUBLIC_BASE_URLand the storage port in step, as it does for the other derived values.
Backup, restore, upgrade, and smoke work unchanged: they read the scheme from
.env.selfhost the way they read the mode flags.
install.sh --ngrok runs the same stack on the machine it runs on and makes it
reachable at an ngrok address — on the free plan, the dev
domain your account comes with, which does not change. It exists so you can
connect your own app and test it on any device, phones included, before you
have a domain: ngrok holds the certificate and forwards to the stack, which
publishes no port on the machine, so nothing in the
Requirements about a public IP, DNS records, Let's Encrypt,
open ports, or firewalls applies.
export NGROK_AUTHTOKEN=<from https://dashboard.ngrok.com/get-started/your-authtoken>
./scripts/selfhost/ngrok-address.sh # prints https://<address>
# Register the OAuth app with the callback URL https://<address>/auth/callback, then:
./scripts/selfhost/install.sh --ngrok \
--email admin@example.com \
--github-oauth-client-id <client-id> \
--github-oauth-client-secret <client-secret>cmpatch selfhost install --ngrok runs the same two scripts on your machine
and asks for everything in between, including the OAuth app.
On a paid ngrok plan the stack can live on a domain you reserved instead —
for instance while the dev domain serves another agent:
ngrok-address.sh --domain patch-test.ngrok.app checks that the account can
serve it, and install.sh --ngrok --api-domain patch-test.ngrok.app installs
on it (cmpatch selfhost install --ngrok --ngrok-domain patch-test.ngrok.app
does both).
What changes, compared with the default HTTPS install:
- One address. The ngrok address carries the API, the dashboard, and
downloads under
/codemagic-patch: Caddy serves a single site,deploy/selfhost/Caddyfile.ngrok.SERVER_URLishttps://<address>,PUBLIC_BASE_URLishttps://<address>/codemagic-patch, the OAuth callback ishttps://<address>/auth/callback, and the storage domain repeats the API domain. Downloads are read-only: onlyGET/HEADreach Silo, never a listing, a signed request, or_internal/. - Reach. No port is published on the machine; the ngrok agent in the stack
(
deploy/selfhost/compose.ngrok.yml) reaches Caddy over the compose network. - The token.
NGROK_AUTHTOKENis required and stored in.env.selfhost. A rerun handed a different one stores it, which is how a token reset in the ngrok dashboard is fixed. An address serves one agent at a time, so stop any otherngrokserving it first — or use a reserved domain, above. - Limits. ngrok's free plan carries 1 GB and 20,000 requests a month, and shows a browser a notice page on its first visit (apps and the CLI are not affected). That is enough to test your own app, not to serve an app in the store. Apps keep the address they were built with, so a production deployment belongs on your own domain.
- Not combinable with
--allow-http,--storage-domain, external storage, or a CDN: the CDN integrations front a domain you own. - Fixed at install time, like the other modes.
--repair-env --api-domainmoves the address and every value derived from it together.
Backup, restore, upgrade, and smoke read the scheme from .env.selfhost and
assemble the same stack; their health checks go through the ngrok address.
Run them from the checkout on this machine (scripts/selfhost/backup.sh and
so on) — the cmpatch selfhost maintenance commands work over ssh.
The Compose stack runs in one of four shapes, selected by two flags in
.env.selfhost:
SELFHOST_DATABASE_MODE \ SELFHOST_STORAGE_MODE |
bundled (Silo in the stack) |
s3 / gcs (external bucket) |
|---|---|---|
bundled (Postgres in the stack) |
Default | Supported |
external (your PostgreSQL) |
Supported | Supported |
The two choices are independent. An env file without the flags runs
bundled × bundled, the shape the rest of this document describes unless
stated otherwise.
Both modes are fixed at install time: re-running the installer with mode flags
against an existing .env.selfhost ignores them with a warning, and moving a
live deployment between modes is a manual data migration — see
Switching modes.
The maintenance scripts (install.sh, backup.sh, restore.sh,
upgrade.sh) read the flags from .env.selfhost and assemble the
docker compose -f file list from them. An unrecognized value fails hard
rather than falling back to bundled, since a typo'd flag would otherwise start
an empty bundled Postgres or Silo alongside an external deployment.
docker-compose.selfhost.yml is the base file; the database and the object
storage each ship as a mode overlay in deploy/selfhost/:
| Flag value | Overlay merged after the base file |
|---|---|
SELFHOST_DATABASE_MODE=bundled (or absent) |
deploy/selfhost/compose.bundled-db.yml |
SELFHOST_DATABASE_MODE=external |
deploy/selfhost/compose.external-db.yml |
SELFHOST_STORAGE_MODE=bundled (or absent) |
deploy/selfhost/compose.bundled-storage.yml |
SELFHOST_STORAGE_MODE=s3 |
deploy/selfhost/compose.external-storage-s3.yml |
SELFHOST_STORAGE_MODE=gcs |
deploy/selfhost/compose.external-storage-gcs.yml |
SELFHOST_SCHEME=http (https or absent selects nothing) |
deploy/selfhost/compose.http.yml, after the storage overlay — see Plain HTTP (no TLS) |
SELFHOST_SCHEME=ngrok |
deploy/selfhost/compose.ngrok.yml, after the storage overlay — see ngrok (no domain) |
Compose commands run by hand need the overlays matching the env file's flags,
with docker-compose.selfhost.override.yml last when the deployment uses one.
For 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 up -dA plain-HTTP deployment adds -f deploy/selfhost/compose.http.yml after the
storage overlay.
Omitting the overlays does not fail. The base file alone is a valid Compose
file describing only Caddy and the server, so it starts with no database and
no object storage: the server crash-loops without DATABASE_URL, and Caddy —
which waits on the server's health — follows it down. The data volumes are
untouched; rerunning with the overlays recovers the stack. Every command
naming docker-compose.selfhost.yml needs one database overlay and one
storage overlay.
--database-mode external connects the server to a PostgreSQL you operate
(for example RDS or Cloud SQL) instead of running one inside the stack.
--database-url is required with it and is recorded as DATABASE_URL in
.env.selfhost; the POSTGRES_* variables are then unused and not written.
Migrations run at server boot against that database, so the connecting role
must be allowed to create and alter tables. If the provider's string ends in
sslmode=require, add &uselibpqcompat=true: the server's PostgreSQL client
otherwise treats require as full certificate verification and fails on the
certificates managed providers issue. The cmpatch selfhost install wizard
adds it for you.
./scripts/selfhost/install.sh \
--api-domain updates.example.com \
--storage-domain storage-updates.example.com \
--email admin@example.com \
--github-oauth-client-id <client-id> \
--github-oauth-client-secret <client-secret> \
--database-mode external \
--database-url postgresql://user:password@db.example.com:5432/codemagic_patchThis example keeps the default bundled storage, so the storage domain still
applies. --database-mode external combines with --storage-mode s3/gcs.
--storage-mode s3 or --storage-mode gcs connects the server to an
object-storage bucket you operate instead of running Silo inside the stack.
Two things change compared to bundled storage:
- There is no storage domain. Caddy serves only the API domain
(
deploy/selfhost/Caddyfile.api-only), so the DNS prerequisite shrinks to the single API record — devices download artifacts from the bucket (or the CDN in front of it), never from this host.--storage-domainis rejected in these modes. --public-base-urlis required. With bundled storagePUBLIC_BASE_URLis derived from the storage domain; with external storage you set it explicitly to the public https URL clients download release artifacts from — the bucket itself, or the CDN in front of it (see CDN in front of external storage). The installer rejects non-https values. This URL is embedded into shipped app binaries asCodemagicPatchDownloadBaseUrl(see App configuration); apps already in the field keep downloading from the URL they were built with.
--s3-bucket is required. The rest is optional: --s3-region (the server
defaults to us-east-1), --s3-endpoint for S3-compatible providers such as
Silo or R2 (empty means AWS), --s3-force-path-style true|false (the server
defaults to false), and --s3-access-key-id / --s3-secret-access-key —
set both, or neither to use the SDK's default credential chain (for example
EC2/ECS instance roles).
./scripts/selfhost/install.sh \
--api-domain updates.example.com \
--email admin@example.com \
--github-oauth-client-id <client-id> \
--github-oauth-client-secret <client-secret> \
--storage-mode s3 \
--s3-bucket my-ota-bucket \
--s3-region eu-central-1 \
--s3-access-key-id <access-key-id> \
--s3-secret-access-key <secret-access-key> \
--public-base-url https://my-ota-bucket.s3.eu-central-1.amazonaws.comFor a new two-bucket setup, also pass --s3-internal-bucket my-private-bucket
(or set S3_INTERNAL_BUCKET). The server routes _internal/ keys, including
both sides of copies, to that private bucket. Names must differ. Keep all
public access blocked on the internal bucket and grant runtime credentials
object access to both buckets. Without this setting, existing one-bucket
routing remains unchanged.
The CLI R2 wizard verifies privacy through Cloudflare's management API:
r2.dev must be disabled for both buckets, and the internal bucket must have
no enabled custom domains. The authenticated S3 endpoint's anonymous response
is not a test of these public settings. Automatic setup retains its disposable
setup token until verification (including retries) finishes, then revokes it.
Automatic R2 setup generates one runtime token combining object writes on the
two buckets, Workers R2 Storage / Read on the account, and Zone / Read
plus Cache Purge on the download zone. The account-level read permission
also allows reading objects in other buckets in that account.
For guided/configured R2, reuse that combined token or create one account-owned
token with the same permissions. Supply it as CLOUDFLARE_API_TOKEN or through
the wizard's password prompt. The CLI derives S3 credentials from this token
when explicit S3 keys are absent and reuses it for zone/privacy verification
and cache purge. Keep this token: it is saved in the server configuration.
Existing explicit S3 keys are still supported. For older installations,
--r2-setup-token / CMPATCH_R2_SETUP_TOKEN is an optional privacy-verification
override; this override is not saved or revoked automatically.
S3 and GCS automatic setup check both bucket names before creating either bucket. Existing names or inconclusive permission checks stop provisioning; use guided setup to review and reuse existing buckets.
Before starting the stack, the installer writes and reads a unique object in
each configured bucket, checks anonymous internal reads and bucket listings
are denied, and compares a public download through PUBLIC_BASE_URL with the
uploaded content. Probe objects are deleted afterward. Failures stop with
correction guidance; --skip-storage-check explicitly skips these checks.
This does not replace the authenticated release smoke test. Backup preserves
the internal bucket setting but does not back up external bucket contents.
Bucket policy. The bucket serves public OTA artifacts anonymously and
keeps the _internal/* staging prefix private.
deploy/selfhost/aws-s3-bucket-policy.example.json
is an AWS bucket policy with the same semantics as the bundled Silo policy
(deploy/selfhost/minio-bucket-policy.json):
anonymous s3:GetObject on the bucket's artifact keys, an explicit Deny
for everything under _internal/*, and no s3:ListBucket grant — a listable
bucket exposes every deployment key. Replace YOUR_BUCKET_NAME with the
bucket name and YOUR_AWS_ACCOUNT_ID with the AWS account the server's
credentials belong to. The aws:PrincipalAccount condition on the Deny is
required because an AWS bucket-policy Deny applies to every principal, not
only anonymous ones; without it the server is denied its own _internal/*
staging reads.
Block Public Access. For this bucket, turn off BlockPublicPolicy (S3
otherwise rejects the policy) and RestrictPublicBuckets (S3 otherwise
ignores the public grant, and anonymous downloads fail), and keep
BlockPublicAcls and IgnorePublicAcls on — the policy grants no ACL-based
access. Account-level settings override bucket-level ones, so both levels must
permit the policy.
Verify the policy after applying it and after any later storage change:
anonymous reads under _internal/* must be denied, and anonymous bucket
listing must fail.
# Must print 403 — never 200, and never an object body:
curl -s -o /dev/null -w '%{http_code}\n' \
"https://YOUR_BUCKET_NAME.s3.<region>.amazonaws.com/_internal/probe"
# Must return AccessDenied — never an XML document listing keys:
curl -s "https://YOUR_BUCKET_NAME.s3.<region>.amazonaws.com/"scripts/selfhost/smoke.sh performs the same two checks against the bucket
endpoint directly (the S3 REST endpoint, or S3_ENDPOINT when set), and
passes only on an explicit 401/403 denial. A 404 fails the check: it
means the probed object merely does not exist in a bucket anonymous readers
can see. The PUBLIC_BASE_URL probes run as well, as delivery-path checks
(see Smoke).
For SSH automation, GCS_CREDENTIALS_JSON_BASE64 can carry the base64-encoded
runtime service-account JSON instead of --gcs-credentials-file. The installer
writes it with private file permissions; do not put setup credentials in this
input. Runtime key replacement remains a manual operation: replace
gcs-service-account.json with mode 0600 and rerun the installer.
GCS uses two buckets instead of one bucket with a private prefix:
--gcs-public-bucket holds the public OTA artifacts, --gcs-internal-bucket
holds internal staging data. Both are required and must differ, since the
public bucket is world-readable and the internal one is not.
--gcs-credentials-file (also required) points at a service-account JSON key:
the installer copies it to <repo root>/gcs-service-account.json
(gitignored), and the gcs overlay bind-mounts that fixed path into the server
container. The Compose path does not support GCE metadata-server ADC; the
server always authenticates with the mounted key. Other credential mechanisms
require a docker-compose.selfhost.override.yml.
./scripts/selfhost/install.sh \
--api-domain updates.example.com \
--email admin@example.com \
--github-oauth-client-id <client-id> \
--github-oauth-client-secret <client-secret> \
--storage-mode gcs \
--gcs-public-bucket my-ota-public \
--gcs-internal-bucket my-ota-internal \
--gcs-credentials-file /path/to/service-account.json \
--public-base-url https://storage.googleapis.com/my-ota-publicBucket IAM. Grant the service account object read/write on both buckets
(for example roles/storage.objectAdmin per bucket). Make the public bucket
anonymously readable by granting allUsers the
roles/storage.legacyObjectReader role, which carries object reads
(storage.objects.get) only:
gcloud storage buckets add-iam-policy-binding gs://my-ota-public \
--member=allUsers --role=roles/storage.legacyObjectReaderDo not grant roles/storage.objectViewer to allUsers: it also includes
storage.objects.list, which makes the bucket anonymously listable and
exposes every deployment key. The internal bucket takes no allUsers binding
of any kind and is reached only through the mounted service-account key.
Verify the split as with S3: an anonymous read from the internal bucket must
be denied, and anonymous listing of the public bucket
(curl -s "https://my-ota-public.storage.googleapis.com/") must return
AccessDenied rather than a key listing. scripts/selfhost/smoke.sh probes
both buckets on storage.googleapis.com directly and requires an explicit
401/403; a 404 fails the check, since it means the bucket itself is
anonymously readable.
--cloudflare works in every storage mode. With external storage the CDN
fronts the PUBLIC_BASE_URL host rather than a storage domain on this
host: the installer verifies the purge credentials against that host, and on
every release the server purges the changed manifest/meta paths in the zone
named by --cloudflare-zone-id. Pass
--public-base-url https://<cdn-domain> — the CDN domain, not the raw bucket
endpoint — and set up the origin per adapter:
- S3: name the bucket exactly after the CDN domain (for example a bucket
named
cdn.example.com) and create a proxied CNAME fromcdn.example.comto the bucket's virtual-hosted-style REST endpoint,cdn.example.com.s3.<region>.amazonaws.com. Set the zone's SSL/TLS mode to Full, not Full (strict): the REST endpoint serves a certificate for*.s3.<region>.amazonaws.com, which does not match a dotted bucket name, so strict validation fails. Do not use the S3 website endpoint as the origin either — it is HTTP-only. - GCS: name the public bucket after the CDN domain (domain-named
buckets require verifying domain ownership with Google) and create a
proxied CNAME from
cdn.example.comtoc.storage.googleapis.com. SSL/TLS mode Full applies for the same certificate-mismatch reason. The internal bucket is never fronted.
Caching and purge then behave exactly as in the bundled setup — see
Optional: front storage with Cloudflare CDN
for what gets purged, and add the Cache Rule from
Add a Cache Rule so the manifest.json / meta.json
paths are actually cached; bundle and patch artifacts are content-addressed
and need no invalidation.
backup.sh records the deployment's modes in the backup's backup-manifest
and dumps only the bundled components: postgres.dump is written only in
the bundled database mode, minio-codemagic-patch.tar.gz only in the bundled
storage mode. External components are covered by your provider's tooling
instead (for example RDS snapshots or point-in-time recovery, bucket
versioning or replication). A coherent restore pairs restore.sh (bundled
components) with restoring each external component to the backup's
created_at timestamp, recorded in versions.txt and named in the backup's
skip warnings. With both components external the backup is configuration-only
— the env file, backup-manifest, and versions.txt, plus the compose
override and gcs-service-account.json when present — and the server keeps
running, since there is nothing to quiesce.
restore.sh refuses a backup whose backup-manifest modes differ from the
current deployment's flags (see Switching modes). It also
cannot undo a database migration the new server image has already run against
an external database: after a failed upgrade, roll that database back with
your provider's mechanism (for example RDS point-in-time recovery), then
restore the remaining bundled components from the pre-upgrade backup.
The gcs-service-account.json key travels with the env file: backup.sh
copies it into the backup, and restore.sh reinstalls it whenever the
current one is missing (or always, with --restore-env).
The scripts do not switch a deployment between modes: installer reruns ignore
mode flags with a warning, and restore.sh refuses cross-mode restores.
Moving an existing deployment onto (or off) an external component is a manual
migration:
- Take a final backup (
backup.sh), then stop the stack. - Database: dump the source database and import it into the target —
for example
pg_dump -Fcfrom the bundled Postgres,pg_restoreinto the external server (or the reverse direction). - Storage: mirror the bucket contents to the target — for example
mc mirror,aws s3 sync, orgcloud storage rsync— and apply the target's bucket policy / IAM described above. - Edit
.env.selfhost: changeSELFHOST_DATABASE_MODE/SELFHOST_STORAGE_MODE, and add or remove the variables each mode uses (DATABASE_URLvsPOSTGRES_*;S3_*/GCS_*plus an operator-setPUBLIC_BASE_URLvsCODEMAGIC_PATCH_STORAGE_DOMAINplusMINIO_ROOT_*). - Restart the stack (
upgrade.sh, ordocker compose up -dwith the matching overlay list). - Take a fresh backup immediately (
backup.sh), so the deployment has a restorable backup in its new modes.
Step 6 matters because restore.sh refuses any backup whose
backup-manifest modes differ from the current flags, with no override flag.
Once step 4 lands, every backup taken before the switch — including backups
with no backup-manifest, which count as bundled × bundled — is no longer
restorable by restore.sh. Their postgres.dump and
minio-codemagic-patch.tar.gz remain ordinary pg_restore / tar artifacts
for manual recovery.
A storage-mode change also changes PUBLIC_BASE_URL. Shipped app binaries
embed it as CodemagicPatchDownloadBaseUrl, so apps in the field keep
requesting the old URL; keep that URL serving artifacts until the binaries
built against it are retired.
The stack serves a web dashboard same-origin on the API domain, for example
https://updates.example.com/. It is bundled into the caddy image —
deploy/selfhost/Dockerfile.caddy builds the SPA and Caddy serves it next to
the /v1 API — so no extra domain, service, or host dependency is needed.
Sign-in shows one button per configured provider (GitHub, Bitbucket Cloud,
and/or GitLab):
the browser is sent to the provider and redirected back to
https://<api-domain>/auth/callback. The first
admin is the email in INITIAL_ADMIN_EMAILS (see
First admin & inviting members); after that,
admins add further members from the dashboard's Members screen (one
Add member flow: existing accounts get access immediately, everyone
else on their first sign-in, with an optional show-once API token for machine
accounts) — or with the CLI commands below.
If browser sign-in fails with a misconfiguration error, the web flow is not
fully configured — most commonly the provider's client secret
(GITHUB_OAUTH_CLIENT_SECRET, BITBUCKET_OAUTH_CLIENT_SECRET, or
GITLAB_OAUTH_CLIENT_SECRET) is missing
from .env.selfhost, or the OAuth App/consumer lacks the callback URL.
The cmpatch CLI drives sign-in, app/deployment setup, and releases. Install it
from a clone of this repository:
yarn install
yarn cli:install-globalRun both at the repository root; cmpatch is then on your PATH. See
cli/README.md for details and how to uninstall.
People sign in with the browser flow (see How users sign in above):
cmpatch login --server-url https://updates.example.comThe session is stored in ~/.codemagic-patch/credentials.json, so later commands run
without re-authenticating.
For machine/CI access you use a personal access token instead of interactive
login — pass it per command via --token or the CODEMAGIC_PATCH_TOKEN environment
variable, or run cmpatch login --token cm_pat_... to store it. See
Machine & CI access for how to mint one.
Registration is invite-only, so accounts are added by invitation rather than self-service. The lifecycle is:
1. The first admin signs in. Because the --email you passed is written to
INITIAL_ADMIN_EMAILS, that email is allowed past invite-only and its account is
created on first sign-in.
cmpatch login --server-url https://updates.example.comYou must use the GitHub, Bitbucket, or GitLab account whose verified primary email
matches --email. The email must be verified with the provider — sign-in
fails otherwise.
If you sign in with a different email, you get the invite-only 403 and stay locked out (no admin exists yet to invite you). Recover by adding the correct email to
INITIAL_ADMIN_EMAILSin.env.selfhostand recreating the stack, or temporarily setREGISTRATION_MODE=open, sign in, then revert it.
2. The team already exists. Self-host provisions a single fixed team on boot
as the fixed default-team (the name is not configurable), and your first sign-in makes
you its owner (which grants iam.manage, the permission needed to invite).
3. Invite members. Invite by email, or by GitHub handle if that is all you
know. The single default-team is resolved automatically, so no team flag is
needed:
# By email — matches the invitee's verified primary email on first sign-in.
cmpatch member invite \
--server-url https://updates.example.com \
--email coworker@example.com \
--role developer
# By GitHub handle — resolved to the account's id at invite time.
cmpatch member invite \
--server-url https://updates.example.com \
--github-handle coworker-gh \
--role developerThe invitee then runs cmpatch login and signs in in the browser: because a
pending invitation matches them (their email, or their GitHub identity for a
handle invite), their account is created and the team role is granted
automatically. Uninvited users are rejected with a 403.
Handle invites bind to the account, not the string. At invite time the server resolves the handle to GitHub's immutable numeric user id and stores that — so the invite is immune to a later handle rename, and a handle that does not exist is rejected (422) up front. The lookup is an unauthenticated call to the GitHub API; it needs outbound network access from the server.
Manage pending invitations with:
cmpatch member invite-list --server-url https://updates.example.com
cmpatch member invite-revoke --server-url https://updates.example.com --invitation-id <id>To grant a role to someone who already has an account, use cmpatch member add.
Before you can wire the SDK or publish an update, create an app and read its deployment key. The object model is:
app → deployments (Staging, Production) → deployment key
1. Create the app. Creating an app automatically provisions two deployments,
Staging and Production:
cmpatch app create --server-url https://updates.example.com --name MyAppThe single default-team is resolved automatically, so no --team is needed.
2. Read the deployment key. List the app's deployments to see the key for each:
cmpatch deployment list --server-url https://updates.example.com --app MyAppEach row shows its deployment key — the DEPLOYMENT_KEY column in the default
table, or the deployment_key field under --format json. Pick the one for the
deployment you ship to — typically Staging while testing, Production for the
rollout — and that
string is the CodemagicPatchDeploymentKey the SDK needs. It is not a secret
and is safe to embed in the app binary. Deployment names match
case-insensitively.
3. Wire it into the app. Put the key together with the two installer URLs into the app's native resources — see App configuration below and the client integration guide — then publish your first update in Publish your first update.
Need another environment beyond
Staging/Production?cmpatch deployment create --app MyApp --name <name>adds one and prints its key.
Point your mobile app at this deployment by setting three native resources
(strings.xml on Android, Info.plist on iOS — or the Expo config plugin
props). Two of them are the URLs the installer prints when it finishes; the
third — the deployment key — is not an installer output. You mint it with the
CLI in Create your first app and deployment
above.
| App resource | Value | Where it comes from |
|---|---|---|
CodemagicPatchApiUrl |
the API origin (SERVER_URL) |
installer Server URL |
CodemagicPatchDownloadBaseUrl |
the artifact origin (PUBLIC_BASE_URL; with bundled storage it ends in /codemagic-patch) |
installer Public base URL |
CodemagicPatchDeploymentKey |
the deployment key that tells the app which deployment to check for updates | cmpatch deployment list — see Create your first app and deployment |
Use each value exactly as printed — CodemagicPatchDownloadBaseUrl must match
PUBLIC_BASE_URL byte-for-byte apart from a trailing slash. The deployment key
is not a secret; it is safe to embed in the app binary. See the client
integration guide and PROTOCOL.md
§Static Delivery Resource Contract for the contract.
With the app wired (deployment key + the two URLs) and a build of that binary
installed on a device or simulator, ship a JS-only update — a new JS bundle and
assets, not a native binary — with release-react. Run it from the React Native
project root:
cmpatch release-react \
--server-url https://updates.example.com \
--app MyApp \
--deployment Staging \
--platform iosrelease-react bundles the app (auto-detecting Metro vs Expo) and uploads the
result. If you omit --target-binary-version, the CLI reads it from the native
project; pass it explicitly to target a specific binary version. Useful flags:
--rollout-percentage <1-100>— release to a fraction of devices first.--mandatory— mark the update as required.--dry-run— build and validate without publishing.
Releases are processed asynchronously. Confirm one finished — using the label or
release id release-react printed — with:
cmpatch release inspect \
--server-url https://updates.example.com \
--app MyApp --deployment Staging --label <label> --waitA device looks up updates by deployment key and binary version; it does not send its native fingerprint during an update check. The fingerprint is a publication-time safety signal and an input to compatible binary-version auto-expansion. The CLI blocks publication by default when its computed fingerprint disagrees with the value already recorded for the explicit target binary version. If a developer approves that disagreement, the release still targets that binary version and can be delivered to its devices.
If an update never arrives, cmpatch doctor --platform ios --app MyApp --deployment Staging
checks the local and deployment configuration, and
cmpatch fingerprint --platform <ios|android> prints the fingerprint the CLI
computes for the project. In CI, authenticate with a token instead of
interactive login (see
Machine & CI access):
CODEMAGIC_PATCH_TOKEN=cm_pat_... cmpatch release-react \
--server-url https://updates.example.com \
--app MyApp --deployment Production --platform android --yesCI, JSON output, --non-interactive, and --yes never approve a fingerprint
disagreement automatically. After independently verifying native compatibility,
pass --allow-fingerprint-mismatch to make that override explicit.
Publishing requires the release.deploy permission — the developer role or
higher (a viewer cannot publish).
People sign in with browser OAuth (GitHub, Bitbucket, or GitLab), but machines (CI
pipelines, scripts, service
accounts) authenticate with a personal access token (cm_pat_...). There are
two ways to get one:
Mint a token for yourself. After signing in, create a token for your own use (e.g. for CI or the smoke/upgrade scripts):
cmpatch token create --server-url https://updates.example.com --name <token-name>Provision a machine/service account. Any team admin (anyone with
iam.manage on the team, such as the team owner) can create a dedicated account
and token in one command:
cmpatch member provision \
--server-url https://updates.example.com \
--email ci-bot@example.com \
--role developerThis creates the account, mints an API token, and grants the team role in a
single call, then prints a one-time cm_pat_... token. Hand it over a secure
channel — it is shown only once. The token is used with:
cmpatch login --server-url https://updates.example.com --token cm_pat_...Use an email not tied to a real GitHub, Bitbucket, or GitLab account for service accounts. Otherwise the owner of that email could sign in and link to the account.
Useful options:
--expires-in-days N— expire the minted token afterNdays (default: no expiry).--display-name <name>/--token-display-name <name>— label the account and token.
provision only onboards brand-new accounts: because it hands back a usable
token, provisioning an email that already has an account is rejected (409) so
nobody can mint a token that impersonates an existing user. To grant a role to
someone who already has an account, use cmpatch member add; to revoke access,
remove their role binding with cmpatch member remove.
The smoke check is an internal server self-test, not a release example: it publishes a synthetic fixture no real device will ever match. To ship a real update, use Publish your first update.
The installer does not run a smoke check. To verify a publish/artifact round trip manually, run it yourself with an API token:
CODEMAGIC_PATCH_TOKEN=cm_pat_... ./scripts/selfhost/smoke.shThe smoke check reuses the bootstrap default-team and a fixed app named
selfhost-smoke (created on first run and kept — nothing temporary is created
or deleted) with its Staging deployment, publishes a release, fetches
manifests and bundle URLs through PUBLIC_BASE_URL, verifies _internal/* is
not anonymously readable, and verifies bucket listing is denied.
Without CODEMAGIC_PATCH_TOKEN the script still runs, but only the unauthenticated
checks: API health, control-plane requests are rejected with 401, _internal/*
is not anonymously readable, and bucket listing is denied. The publish round
trip is skipped.
Create a manual backup before upgrades or risky changes:
./scripts/selfhost/backup.shFrom your own machine, cmpatch selfhost backup runs exactly this script over
ssh — see Maintaining the server from your own machine.
Note that the backup stops the server container while the dump runs to quiesce writes and restarts it when done — expect a short API outage. The upgrade script runs a backup first, so the same applies at the start of every upgrade. (With both the database and storage external there is nothing to dump: the backup is configuration-only and the server keeps running.)
The backup directory contains:
env.selfhost
backup-manifest (records the deployment's database/storage modes)
versions.txt (its created_at= line is the point-in-time anchor)
postgres.dump (bundled database mode only)
minio-codemagic-patch.tar.gz (bundled storage mode only)
Deployments that use a docker-compose.selfhost.override.yml get that file
copied into the backup as well, as is gcs-service-account.json when present.
External components are never dumped — see
Backups with external components.
Backups are written under backups/ by default and are ignored by git.
Restore the bundled components of one backup together:
CODEMAGIC_PATCH_TOKEN=cm_pat_... ./scripts/selfhost/restore.sh backups/codemagic-patch-selfhost-<timestamp>The restore command validates the backup shape, asks for explicit confirmation,
replaces the local data volumes the backup's backup-manifest covers (both
PostgreSQL and Silo in the default bundled modes), starts the stack, checks health,
and runs smoke — the full publish smoke when CODEMAGIC_PATCH_TOKEN is provided,
unauthenticated checks only otherwise. A smoke failure at this point means
post-restore validation failed; the volume restore itself has already
completed.
Use --restore-env only when you also want to replace the current
.env.selfhost with the copy from the backup.
From your own machine, cmpatch selfhost restore lists the backups on the
server and runs this script against the one you pick — see
Maintaining the server from your own machine.
A backup taken from a deployment with different mode flags is refused — see
Switching modes — and external components are not
restored by this script: bring each one back to the backup's created_at
timestamp with your provider's tooling, as described in
Backups with external components.
Check your own compose invocations first. The upgrade command assembles the compose file list itself, and an
.env.selfhostwithout theSELFHOST_*_MODEflags keeps running the default bundled stack. Runbooks, monitoring checks, and automation that calldocker compose ... -f docker-compose.selfhost.ymldirectly must pass the mode overlays themselves, or they bring up a stack with no database and no object storage. See Mode flags and compose overlays.
Migrating from a pre-OAuth install: OAuth sign-in is mandatory — a stack with no configured OAuth provider will refuse to boot after upgrading — and each configured provider additionally requires its client secret (the confidential web code exchange). The upgrade command checks this before touching anything and fails fast when no provider is configured in
.env.selfhost, or when a configured provider's client id is missing its secret. Before upgrading, create a GitHub OAuth App, Bitbucket OAuth consumer, or GitLab application (callback URLhttps://<api-domain>/auth/callback, client secret generated) and add its client id and secret —GITHUB_OAUTH_CLIENT_ID/GITHUB_OAUTH_CLIENT_SECRETorBITBUCKET_OAUTH_CLIENT_ID/BITBUCKET_OAUTH_CLIENT_SECRETorGITLAB_OAUTH_CLIENT_ID/GITLAB_OAUTH_CLIENT_SECRET— to.env.selfhost. The other OAuth values migrate automatically:OAUTH_CLI_AUTH_SECRETis generated if missing (a manually set value must be 32+ chars; an existingOAUTH_DEVICE_POLL_TOKEN_SECRETkeeps working as a permanent fallback),INITIAL_ADMIN_EMAILSis backfilled fromACME_EMAIL(the admin email given at install time), andGITHUB_OAUTH_ALLOWED_REDIRECT_URIS(when GitHub is configured) andCODEMAGIC_PATCH_CADDY_IMAGEare defaulted when missing. Existing API tokens keep working, and existing admins link to their provider identity on first sign-in (matching email). For the full upgrade smoke, get acm_pat_...token — sign in withcmpatch login, then runcmpatch token create.
Rebuild from the checked-out source tree and recreate the stack:
./scripts/selfhost/upgrade.shFrom your own machine, cmpatch selfhost upgrade fast-forwards the server's
checkout first and then runs this script — see
Maintaining the server from your own machine.
Move to a specific server image:
./scripts/selfhost/upgrade.sh ghcr.io/example/codemagic-patch-server:v1.2.3The upgrade command prints the current image, creates a fresh backup unless
--i-have-a-backup is passed, updates or rebuilds the server image, recreates
the stack, waits for health, and runs smoke. The caddy (web dashboard) image is
always rebuilt from the checked-out source — the dashboard ships inside the
caddy image, so this keeps it in step with the server even when a pinned server
image is pulled, and upgrades never ship a stale dashboard. Set
CODEMAGIC_PATCH_TOKEN=cm_pat_... to run the full publish/artifact smoke; without it
only unauthenticated checks run. In automation, make sure the token is actually
set if you rely on the full smoke — a missing token downgrades to the
unauthenticated checks with a warning instead of failing.
Automatic rollback is not built in. To roll back, restore a
known-good backup or set CODEMAGIC_PATCH_SERVER_IMAGE in .env.selfhost back to a
previous image and rerun the upgrade command. To roll back only the dashboard,
set CODEMAGIC_PATCH_CADDY_IMAGE to a previously built image and recreate the stack
(the upgrade command always rebuilds the caddy image from source).
Once a server exists, cmpatch can run its maintenance scripts over ssh, so
routine work needs no manual login:
cmpatch selfhost backup
cmpatch selfhost restore
cmpatch selfhost upgradeEach command runs the same scripts/selfhost/*.sh on the server and streams its
output; nothing is reimplemented locally, and the direct paths documented above
keep behaving exactly as before.
The first command against a server pairs with it. cmpatch generates a
dedicated ssh key for that host (under ~/.codemagic-patch/keys/) and installs
its public half in the server's authorized_keys, labelled cmpatch:
- If your own ssh setup already reaches the server, it is used for that first connection to install the key and never again. ssh asks for anything it needs itself — a password, a key passphrase — and may ask twice: once to check the connection, once to install the key.
- If it does not — an EC2
.pemyou never wired up, GCP metadata keys, no keys at all, a mistyped password or address —cmpatchoffers four ways forward and returns to the same choice after any that does not connect: a private key file (an AWS.pemis the common case), a retry so sshd can ask for the password, one line to paste into your provider's browser SSH console (it waits for the paste to take effect), or a corrected user or address. A changed host key is diagnosed with thessh-keygen -Rcommand that clears it.--ssh-key <path>supplies the first-connection key without the prompt.
Pass the address explicitly the first time, or whenever you have more than one server:
cmpatch selfhost backup ubuntu@203.0.113.7Afterwards the pairing is stored against the server's URL, so the plain form
works. Every later command authenticates with the cmpatch key alone — your own
ssh configuration is a bootstrap channel, not a dependency. The key is per
machine: a teammate, or your next laptop, pairs the same way.
backupstops the server briefly for a consistent snapshot, writes it to~/codemagic-patch-backups/<timestamp>on the server, and offers to download a copy (--download). On deployments with an external database or S3/GCS storage it names the components it does not cover before asking, since those need your provider's own tooling.restorelists the backups on the server — including the automatic pre-upgrade and safety ones — takes a safety backup of the current data, and replaces the data with the backup you pick, behind a typed confirmation.--restore-envalso restores the settings file (usually not what you want: it replaces your current domains and keys). The publish check is deferred: sign in withcmpatch loginand publish a release to confirm end to end.--smokeruns that check during the restore instead, but only a token that already existed when the backup was taken can pass it (the restore replaces the database its row lives in), so it is supplied explicitly:CODEMAGIC_PATCH_TOKEN=cm_pat_... cmpatch selfhost restore --smoke.upgradefast-forwards the server's checkout to the default branch first — on every path, including--image, because the dashboard image is rebuilt from that checkout either way — and refuses a checkout that is dirty, detached, diverged, or tracking another repository. Then it runsupgrade.sh, and reports the source revision and the server image separately.
Every prompt has a flag equivalent, and --non-interactive runs the flags-only
form. The full output of each run is kept at
~/.codemagic-patch/logs/<timestamp>-<command>.log.
With the default bundled modes, the durable self-host state lives in two Compose volumes:
- PostgreSQL data.
- Silo data.
An external database or external storage keeps the corresponding state with your provider instead; reset it there. Do not reset only one of the two components. The database and object storage represent one logical deployment state.
With the bundled storage, Silo is exposed on the storage domain so mobile
devices can fetch OTA artifacts directly. The Compose stack fixes the bucket
name to codemagic-patch. The bucket policy allows anonymous GetObject for
public OTA artifact keys and denies anonymous reads under _internal/*.
Bucket listing is not public.
External storage modes must uphold the same policy on the operator's bucket — see the bucket policy and IAM guidance in External database and external storage.
The self-host smoke test treats unsafe _internal/* exposure as a failure.
With the bundled storage, clients by default fetch artifacts straight from
Silo on the storage domain (DELIVERY_ADAPTER=base-url). That is fine for
evaluation, but for production we strongly recommend a CDN (this section or
CloudFront) so devices download
from the edge rather than the single host. You can put
Cloudflare CDN in front of the storage origin: you proxy the same
storage domain through Cloudflare, so PUBLIC_BASE_URL is unchanged. On every
release the server then purges the edge cache for the paths that changed,
reducing stale-edge risk while preserving the release worker's best-effort
purge semantics. (For a CDN over external storage — where there is no
storage domain — see
CDN in front of external storage; the
Cache Rule guidance below applies to both.)
Only the JSON delivery files need routine purging — per-hash and fallback
manifest.json plus meta.json. Selecting a purging delivery adapter also
switches their default header from no-cache, must-revalidate (the base-url
policy, where nothing could invalidate a shared cache) to
public, max-age=0, s-maxage=300, must-revalidate. Clients revalidate
immediately while a shared CDN cache may retain them for five minutes. Bundle
and patch artifacts are content-addressed (max-age=1y, immutable), so a new
release is a new path and never needs routine invalidation. Purge is
best-effort: a CDN API failure is logged but never blocks a release.
Create a Cloudflare API Token scoped to Zone → Cache Purge — either a
user-owned token (My Profile → API Tokens → Create Token) or an account-owned
token (your account → Manage Account → API Tokens, cfat_… format); not the
legacy Global API Key — and note the Zone ID of the zone holding your
storage domain. Pass them to the installer:
./scripts/selfhost/install.sh \
--api-domain updates.example.com \
--storage-domain storage-updates.example.com \
--email admin@example.com \
--github-oauth-client-id <client-id> \
--github-oauth-client-secret <client-secret> \
--cloudflare \
--cloudflare-api-token <cf-token> \
--cloudflare-zone-id <cf-zone-id>The installer verifies the credentials with a harmless no-op URL purge against
the storage domain, confirming the token can actually purge that zone — the same
Zone → Cache Purge permission the server uses, so a correctly scoped token passes
without needing Zone Read, and both user-owned and account-owned tokens work.
--skip-cloudflare-check bypasses this,
and --cloudflare-api-base-url <url> overrides the API endpoint. The interactive
installer also offers this as a prompt, and passing the credentials alone implies
--cloudflare. It writes these to .env.selfhost:
| Variable | Purpose |
|---|---|
DELIVERY_ADAPTER |
cloudflare to enable edge purge (default base-url). |
CLOUDFLARE_API_TOKEN |
Token scoped to Zone → Cache Purge. |
CLOUDFLARE_ZONE_ID |
Zone containing the storage domain. |
CLOUDFLARE_API_BASE_URL |
Optional API endpoint override. |
Caddy obtains the storage domain's Let's Encrypt certificate over HTTP, which fails if Cloudflare already proxies (orange-cloud) that hostname. Keep the storage domain DNS-only (grey cloud) until the install finishes and Caddy has a certificate, then switch it to proxied. The installer prints this reminder when Cloudflare is enabled.
Purge does nothing unless Cloudflare is actually caching the manifest/meta JSON.
After proxying the storage domain, add a Cloudflare Cache Rule that makes the
manifest.json / meta.json paths eligible for caching — for example, a rule
for the storage hostname with cache status Eligible for cache, Edge TTL
set to Use cache-control header if present, bypass cache if not, and
Browser TTL set to Respect origin TTL. This rule is still needed because
Cloudflare does not cache JSON by default. The Browser TTL setting matters too:
Cloudflare's zone default (four hours on the Free plan) otherwise rewrites the
origin's max-age=0 to max-age=14400, and iOS devices would then keep a
stale manifest for up to four hours after a release — a purge cannot reach a
device cache. Do not add a second Edge TTL override — and delete the
two-hour .json Edge TTL rule if an earlier version of this guide had you
create one: Cloudflare Origin Cache
Control respects the five-minute s-maxage default, while the Free-plan
two-hour floor applies only to an explicit Edge TTL override, which takes
precedence over s-maxage when present. Releases purge the mutable paths
automatically.
Delivery config is only written on the initial install;
re-running the installer with --cloudflare on an existing .env.selfhost is
ignored with a warning. To turn it on later, edit .env.selfhost by hand — set
DELIVERY_ADAPTER=cloudflare and the CLOUDFLARE_* values (see
.env.selfhost.example) — then recreate the stack
with ./scripts/selfhost/upgrade.sh. Do the DNS-only → proxied and
Cache Rule steps above as well.
Delete the MANIFEST_CACHE_CONTROL line while you are in there. Installs from
before CDN delivery pinned no-cache, must-revalidate, an explicit value beats
the adapter-derived default, and leaving it makes the edge bypass every
manifest — the installer warns about this on the next run.
CloudFront is the CNAME-compatible alternative to Cloudflare. It uses the same
PUBLIC_BASE_URL, honors the five-minute manifest s-maxage, and lets the
server submit distribution-scoped invalidations. The first 1,000 invalidation
paths per AWS account each month are free; deployment deletion uses one
deployment-prefix wildcard instead of one path per artifact.
Request the viewer certificate in ACM us-east-1 and create an IAM identity
whose only permission is cloudfront:CreateInvalidation on the selected
distribution ARN. Put that scoped key—not broad AWS credentials—in
.env.selfhost. Configure the default behavior with managed
CachingOptimized, HTTPS, and no viewer Host forwarding.
Bundled storage needs three distinct hostnames:
updates.example.com A/AAAA <server-ip> # API
storage-updates.example.com A/AAAA <server-ip> # viewer, initially
origin-storage-updates.example.com A/AAAA <server-ip> # CloudFront origin
Keep the viewer record on the server throughout install. Create the
distribution with the origin hostname, add the custom origin header
X-Codemagic-Patch-Origin-Verify: <random-secret>, and set the alternate name
to the viewer hostname. Then install:
./scripts/selfhost/install.sh \
--api-domain updates.example.com \
--storage-domain storage-updates.example.com \
--storage-origin-domain origin-storage-updates.example.com \
--email admin@example.com \
--github-oauth-client-id <client-id> \
--github-oauth-client-secret <client-secret> \
--cloudfront \
--cloudfront-distribution-id <distribution-id> \
--cloudfront-access-key-id <purge-access-key-id> \
--cloudfront-secret-access-key <purge-secret-access-key> \
--cloudfront-origin-verify-secret <random-secret>Omit both access-key flags to use an EC2/ECS role. Before changing DNS, fetch a
real manifest through the distribution domain and expect 200 plus
x-cache: Miss from cloudfront. Then test the final viewer hostname against
that distribution without changing DNS; this must pass normal TLS certificate
verification and return 200 plus an x-cache header:
curl -fsSI \
--connect-to "storage-updates.example.com:443:d111111abcdef8.cloudfront.net:443" \
"https://storage-updates.example.com/codemagic-patch/<deployment-key>/meta.json" \
| grep -Ei '^(HTTP/|x-cache:)'Do not add -k or --insecure. Finally, fetch the same path directly from the
origin hostname without the custom header and expect 403. Only after all
three checks pass should you change the viewer record to a CNAME targeting
CloudFront and confirm Miss then Hit.
The generated env uses SELFHOST_STORAGE_ORIGIN_MODE=cdn-origin and keeps
no CLOUDFRONT_ORIGIN_VERIFY_SECRET_PREVIOUS outside a rotation. To rotate
without a 403 window: set current=new, add previous=old, rerun install; update
CloudFront and wait for Deployed; delete the previous line and rerun. After
viewer DNS
cutover, Caddy HTTP-01 renewal warnings for the no-longer-routed viewer
certificate are expected; the origin certificate still renews normally.
External storage keeps SELFHOST_STORAGE_ORIGIN_MODE=direct and uses no origin
hostname or verification secret:
- AWS S3: choose the bucket as an S3 origin with Origin access: Public.
- GCS: custom HTTPS origin
storage.googleapis.com, Origin path/<public-bucket>. - Other S3-compatible storage: custom HTTPS origin at
S3_ENDPOINT, with any provider-required bucket path.
Set --public-base-url to the viewer domain and pass the CloudFront flags. If
the old value was a raw bucket URL, existing binaries and already-published
manifests keep their old absolute URLs; changing the value does not rewrite
them immediately, so keep the bucket public during migration.
S3 Origin Access Control is safe only if PUBLIC_BASE_URL has always been a
custom viewer domain. Otherwise privatizing the bucket breaks old binaries and
old manifest artifact URLs. A naive OAC allow can also expose _internal/* to
the CloudFront service principal; use
deploy/selfhost/aws-s3-bucket-policy.cloudfront-oac.example.json,
which explicitly denies that prefix.
The complete console settings, pre-cutover commands, adoption flow, IAM policy,
and rotation checklist live in
docs-site/docs/reference/cdn.mdx.
The install wizard can create DNS records with a Cloudflare API token, or open a Domain Connect approval page when your DNS provider advertises the Patch template. Manual record creation remains available. Choose once per DNS zone; the wizard checks propagation after setup and keeps the existing ACM and CloudFront readiness checks.
Use --dns-setup manual to keep all DNS changes manual. The default auto asks
in interactive installs when the wizard can add the records for you — a zone on
Cloudflare, or a provider that advertises the Patch Domain Connect template —
and otherwise prints the records to add without asking; unattended installs
leave DNS manual. --dns-setup cloudflare explicitly enables Cloudflare record
creation; existing records that need replacement still require interactive
review.
The records point at the server's public IPv4 address, which the wizard reads
from the server itself. A server that cannot report one — behind NAT (Oracle
Cloud, a VM behind a router), or IPv6-only — is asked for it, with the address
the ssh hostname resolves to offered as the default; --public-ip <address>
supplies it without a prompt, and a scripted run with --dns-setup cloudflare
must pass it. While a record is
being waited for, the wizard explains what it found the first time it sees an
old record or a Cloudflare-proxied one, and keeps checking.
For Cloudflare, create a separate temporary token with Zone / Zone / Read
and Zone / DNS / Edit, scoped to the zones you will configure. The
pre-filled link also adds Zone / Cache Rules / Edit and Zone / Zone
Settings / Read: with Cloudflare as the CDN, the wizard then adds the cache
rule, checks SSL/TLS and turns the proxy on for the download domain after the
server is up, instead of walking you through them. Enter it at
the password prompt, or supply CMPATCH_DNS_CLOUDFLARE_API_TOKEN through your
shell's secret-input mechanism. The wizard retains it through the final DNS
change, does not save it in the server or CLI configuration, and reminds you to
revoke it after the run. Runtime cache-purge credentials are separate.
Initial records and CloudFront/ACM records use DNS-only mode. Existing matching records are reused; replacements show the old and new values for approval. Multiple conflicting records require manual resolution. If a request loses its response, inspect the provider's current record before retrying.
Domain Connect availability depends on provider onboarding of the Patch record templates; the option is offered only after the wizard has found the template advertised for your zone, and a zone without it uses the manual records for the rest of the run. Opening the approval page does not establish that the record changed; the wizard still waits for DNS or download verification.