diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index f40b7dc..75449af 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -64,6 +64,17 @@ jobs:
- name: Check links and paths
run: docs/check-links.sh
+ # The realm writer of the deploy guide (Story 10.3); needs jq, which the runner has.
+ - name: Test the realm writer
+ run: deploy/realm/test.sh
+
+ # The Compose reference example parses with the placeholder settings, and each required
+ # variable stops `config` when it is missing (deploy/compose/test.sh). Nothing is started.
+ - name: Validate the Compose example
+ run: |
+ docker compose -f deploy/compose/compose.yaml --env-file deploy/compose/.env.example config --quiet
+ deploy/compose/test.sh
+
commands:
name: Commands
runs-on: ubuntu-24.04
diff --git a/README.md b/README.md
index 3350de5..5c9725e 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,8 @@ and deployment all live in this repository.
- [Developer quickstart](docs/quickstart.md): run the local stack and every test suite.
- [Build guide](docs/build/README.md): build, flash and add your own Node and Hub.
+- [Deploy guide](docs/deploy/README.md): run Coldframe on your home server, then
+ [build your own apps](docs/build/apps.md).
- [Hardware design](hardware/README.md): schematics, BOM, wiring, enclosure and probe sealing.
- [Hub radio coexistence spike](docs/spikes/hub-radio-coexistence.md): what the Hub's radio can do at once.
@@ -22,7 +24,7 @@ and deployment all live in this repository.
| [`packages/`](packages) | Contracts, shared libraries and generated clients the runtimes reference |
| [`tests/`](tests) | All tests, split by language |
| [`aspire/`](aspire) | The local development stack, which is also the integration-test host |
-| [`deploy/`](deploy) | Helm charts, Fleet bundles and a Compose example |
+| [`deploy/`](deploy) | Helm charts, Fleet bundles and a Compose reference example ([`deploy/compose`](deploy/compose/README.md)); deploy at home with [`docs/deploy`](docs/deploy/README.md) |
| [`hardware/`](hardware) | The reference Node and Hub design: schematics, BOM, wiring, enclosure |
| [`docs/`](docs) | Guides and references |
diff --git a/_bmad-output/implementation-artifacts/spec-10-3-deploy-coldframe-at-home-and-build-my-own-apps.md b/_bmad-output/implementation-artifacts/spec-10-3-deploy-coldframe-at-home-and-build-my-own-apps.md
new file mode 100644
index 0000000..1524cfc
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/spec-10-3-deploy-coldframe-at-home-and-build-my-own-apps.md
@@ -0,0 +1,202 @@
+---
+title: 'Deploy Coldframe at home and build my own apps'
+type: 'feature'
+created: '2026-10-19'
+status: 'awaiting-operator'
+baseline_revision: '05c2837148ef2a04e8e4b3ad4e6bdd265d5800cf'
+review_loop_iteration: 0
+followup_review_recommended: true
+context:
+ - '{project-root}/_bmad-output/implementation-artifacts/epic-10-context.md'
+warnings: [oversized]
+deferred: []
+operator_actions:
+ - "Follow docs/deploy/README.md end to end on a real domain with its zone at Cloudflare and a single-node RKE2 home server, at a published release tag, and fix in the guide every step that differs from what you see (RKE2 install, firewall, namespace, Secrets, realm, split DNS, first sign-in)."
+ - "Build the Android app with your own Firebase project and the iOS app with your own Apple Developer team and APNs key, following docs/build/apps.md, install both on your phones, sign in against your deployment and receive a push; correct any console step or command in the guide that differs."
+ - "Once a release tag publishes the images, start deploy/compose/ with docker compose up on a LAN Docker host following deploy/compose/README.md, sign in to the web app, and fix compose.yaml or the README where the stack does not come up."
+---
+
+
+
+## Intent
+
+**Problem:** The deploy facts are spread across runbooks written for operators (`docs/operations/install.md`, `split-dns.md`, `deploy/SECRETS.md`, `deploy/charts/README.md`). Nothing installs RKE2 itself, nothing tells a newcomer how to turn the localhost realm into one for their domain, and nothing covers the first sign-in. `docs/build/apps.md` is a stub, and the Compose example that `deploy/README.md:14`, `README.md:25` and `docs/quickstart.md:469` promise does not exist.
+
+**Approach:** Add one ordered adopter guide in `docs/deploy/` that walks domain → RKE2 + Fleet → Secrets → Keycloak realm → split DNS → first sign-in, linking the Epic 2 runbooks for detail rather than copying them. Add a small tested script that writes a realm file for the adopter's hosts. Expand `docs/build/apps.md` into a full build-your-own-apps guide. Add `deploy/compose/` as a reference-only example. Extend the `Docs` workflow so links resolve, the realm script's tests pass, and `docker compose config` validates the example.
+
+## Boundaries & Constraints
+
+**Always:**
+- **Order is fixed.** `docs/deploy/README.md` has the six sections in this order, numbered: 1 Domain and DNS API token, 2 RKE2 and Fleet, 3 Secrets, 4 Keycloak realm and Phase Two, 5 Split DNS, 6 First sign-in. It links `docs/operations/install.md`, `split-dns.md` and `restore.md` (the Epic 2 runbooks) and `deploy/SECRETS.md`.
+- **Link, don't duplicate.** The runbooks stay the source of truth for commands that already exist there (Fleet install, Secret creation, cert-manager flags, split DNS records, verification). The guide gives the order, the decisions and the missing steps, then links the runbook section by anchor.
+- **Missing steps the guide adds:**
+ - RKE2 v1.36.4+rke2r1 install on a single node (`curl -sfL https://get.rke2.io | sudo INSTALL_RKE2_VERSION=v1.36.4+rke2r1 sh -`, enable `rke2-server`, kubeconfig at `/etc/rancher/rke2/rke2.yaml`) and pointing it at the LAN resolver of section 5 (forward reference: the node must use split DNS; `split-dns.md#the-node-and-the-cluster-must-use-this-resolver`).
+ - Domain: a dedicated subdomain, zone at Cloudflare, the token scopes (Zone→DNS→Edit, Zone→Zone→Read) as in `split-dns.md` step 1, the three hosts ``, `api.`, `auth.`.
+ - Realm: run `deploy/realm/prepare-realm.sh`, create the `coldframe-realm` ConfigMap from its output, and what Phase Two adds (organizations, the `coldframe` email theme with the home-network hint, the Invitation required action, Verify email). SMTP comes from `coldframe-smtp` at first import; the realm is imported once only.
+ - First sign-in: open `https://` on the home Wi-Fi, Register, verify the email (needs working SMTP), create a Site; whoever creates it is its Owner. Then the permanent Keycloak admin (`deploy/SECRETS.md` post-start step). Then the install verification checklist.
+- **Apps guide facts come from the build files.** Android: `coldframe.serverUrl`, `coldframe.keycloakIssuer`, `coldframe.keycloakClientId` and the four `coldframe.firebase*` Gradle properties (`apps/kt/android/build.gradle.kts:13,40-53`); no `google-services.json`, no plugin. iOS: `Config/Coldframe.local.xcconfig` with the keys of `Config/Coldframe.xcconfig:9-34`, including the `https:/$()/host` escape, `COLDFRAME_PUSH`, `DEVELOPMENT_TEAM`, and XcodeGen. Server side: the `coldframe-push` Secret and `push.apns.topic` = the iOS bundle id.
+- **Say what the adopter must change together.** Changing the Android `applicationId` or iOS bundle id is allowed alone; the OIDC redirect scheme `com.escendit.coldframe` is a constant in `CoreConfig.kt:35`, `build.gradle.kts:56` and the realm, so the guide says to keep it (or change all three together). The Firebase Android app must use the applicationId the adopter builds.
+- **Android release signing without code changes:** document `./gradlew :android:assembleRelease` then `zipalign` and `apksigner sign` from the SDK build-tools with the adopter's own keystore, and `adb install`. A debug build via `installDebug` is the quick path.
+- **Compose is labelled.** `deploy/compose/README.md` and the top comment of `compose.yaml` say "reference example only, not the supported deployment (NFR7); the supported path is `docs/deploy/`". No TLS, no backups, web app only (release mobile builds need HTTPS with a public certificate).
+- **Compose topology** matches the charts: postgres (`postgres:18.6`, databases `coldframe`, `temporal`, `temporal_visibility`, `keycloak` via an init script), nats 2.15.0 with JetStream, a one-shot temporal-schema (admin-tools 1.31.2, the four `temporal-sql-tool` commands of `deploy/charts/temporal/templates/schema-job.yaml:42-45`) and namespace creation, temporal server 1.31.2, migrations (one-shot, before server), keycloak (`start --optimized --import-realm`, realm mounted from a prepared file), server, web. Images `ghcr.io/escendit/coldframe/:${COLDFRAME_VERSION}` with no default and no `latest`. Every secret and host comes from `.env` (template `.env.example`; `.env` is gitignored by `*.env`). Env var names come from the chart templates / `deploy/images/README.md`.
+- **Compose host name:** one `COLDFRAME_HOST` (a LAN name or IP of the Docker host) used for the browser-facing URLs and by the containers, so the Keycloak issuer is the same string for the browser and the Server (`Identity__RequireHttpsMetadata=false`, `KEYCLOAK_ALLOW_INSECURE_HTTP=true`, `SESSION_COOKIE_SECURE=false`). Ports: web 3000, server 8080, keycloak 8180.
+- **`prepare-realm.sh`** (bash + jq): reads `aspire/keycloak/realms/coldframe-realm.json` (or `--in`), sets the `coldframe-web` client's `redirectUris` to `[/.oidc/signin/callback]` and its `post.logout.redirect.uris` to `/.oidc/signout/callback` (paths from `apps/ts/web/src/lib/server/auth-handle.ts:10-12`), leaves every `${…}` placeholder and the mobile client untouched, writes to `--out` or stdout. Requires `https://` unless `--allow-http`.
+- **New marker ``** in `docs/check-commands.sh`: the block needs a phone, Xcode, the Android SDK or a cluster; CI does not run it. `--list` shows it. Every bash/sh block in `docs/build/apps.md` is marked.
+- CI: in `docs.yml`'s `links` job add `deploy/realm/test.sh` and `docker compose -f deploy/compose/compose.yaml --env-file deploy/compose/.env.example config --quiet`. Add `deploy/compose/README.md` to `check-links.sh`'s default roots (`docs/` is already covered). Pin nothing new unless an action is added (then by SHA with a release comment).
+- Keep `docs/build/apps.md` at its path: `docs/operations/release.md:56` and `deploy/release/release-notes.sh:155` link it. Update `apps.md:4` ("Story 10.3 expands it") and `deploy/README.md:14` accordingly.
+
+**Never:**
+- Don't edit `.github/workflows/ci.yml` (reruns the slow Images job), app code, charts, firmware, or the Server.
+- Don't edit `sprint-status.yaml`.
+- Don't commit keys, team ids, `google-services.json`, keystores or a filled `.env`.
+- Don't check external `http(s)` links or run `docker compose up` in CI.
+- Don't present Compose as supported or claim the guide was followed end to end on real hardware (that is Story 10.5).
+
+## I/O & Edge-Case Matrix
+
+| Scenario | Input / State | Expected Output / Behavior | Error Handling |
+|----------|--------------|---------------------------|----------------|
+| Realm for a domain | `--web-origin https://coldframe.example.org` | web redirect `https://coldframe.example.org/.oidc/signin/callback`, post-logout `…/signout/callback`; `${COLDFRAME_*}` placeholders and mobile client unchanged; valid JSON | No error expected |
+| Trailing slash | `--web-origin https://x.example.org/` | same as without the slash | No error expected |
+| HTTP origin | `--web-origin http://192.168.1.10:3000` | refused | exit 2, says `--allow-http` is for the Compose example only |
+| HTTP allowed | `--allow-http --web-origin http://192.168.1.10:3000` | redirects use that origin | No error expected |
+| Bad origin | missing, or with a path (`https://x.org/app`), or no scheme | refused | exit 2 with usage |
+| Missing input | `--in nope.json` | refused | exit 2 names the file |
+| `--out` | `--out realm.json` | file written, nothing on stdout | No error expected |
+| Manual block | `` + a bash block | not run; `--list` shows `manual` | No error expected |
+| Compose config | `.env.example` values | `docker compose config --quiet` exits 0 | a missing required variable fails |
+
+
+
+## Code Map
+
+- `docs/operations/install.md:44-59,78-120,123-136,272-320` -- Fleet install prerequisites, Secrets + realm step, SMTP/Phase Two note (what to enable in the realm), site values, verification checklist ("Sign-in from a phone and a browser"). Link targets; read-only.
+- `docs/operations/split-dns.md:22,37,58,132,151,169,199,236` -- headings to link by anchor (token, records, node resolver, phones, verify, private-CA fallback). Cloudflare only (:18-20). Read-only.
+- `docs/operations/restore.md` -- Epic 2 restore runbook; link.
+- `deploy/SECRETS.md:18-28,30-48,71-127,139-141` -- the Secret table, push section, creation commands, permanent admin. Link; do not restate keys beyond naming each Secret.
+- `deploy/charts/README.md:73-84` -- prerequisites 1-4 (realm "with the redirect URIs of your host names"); point item 3 at `prepare-realm.sh`.
+- `deploy/fleet/values.example.yaml:22-48` -- site values the adopter edits (hosts, issuer, invitation redirect, ACME email).
+- `deploy/rke2/README.md` -- Traefik HelmChartConfig (applied by the `rke2-traefik` bundle; don't apply by hand).
+- `aspire/keycloak/realms/coldframe-realm.json:6-11,26,29-38,45-63,81-97` -- realm flags, SMTP placeholders, web client (localhost redirects at :59,:63), mobile client. Input of the script; read-only.
+- `aspire/keycloak/themes/coldframe/email/messages/messages_en.properties:5-7` -- the home-network hint text to quote.
+- `apps/ts/web/src/lib/server/auth-handle.ts:10-12` -- web callback paths.
+- `apps/kt/android/build.gradle.kts:13,21,28,40-56` and `packages/kt/core/src/commonMain/kotlin/com/escendit/coldframe/core/signin/CoreConfig.kt:26-48` -- Android properties, applicationId, redirect scheme, `.invalid` defaults.
+- `apps/swift/ios/Config/Coldframe.xcconfig:3-34`, `apps/swift/ios/project.yml:6,36-45`, `apps/swift/ios/Config/.gitignore:1` -- iOS keys, URL escape, XcodeGen, Kotlin framework pre-build (needs JDK 25 + Android SDK), bundle id.
+- `apps/cs/server/Notifications/Push/PushOptions.cs:60-103`, `deploy/charts/server/values.yaml:77-87` -- server push options; topic = bundle id.
+- `docs/quickstart.md:152-231` and `docs/bench/push-checklist.md:57-101` -- existing runnable app/push commands to reuse and link.
+- `deploy/images/README.md:6-90` -- images, ports, env vars for Compose. `deploy/charts/{server,web,keycloak,temporal,nats}/templates/*.yaml` -- exact env var wiring (server `deployment.yaml:51-127`, web `:34-52`, keycloak `:44-129`, temporal `:39-58`, nats args `:28-31`).
+- `docs/check-commands.sh:1-20,59-80,160-195` -- marker parsing and dispatch; add `ci:manual`. `docs/test.sh` -- fixture tests in `expect/pass/failed` style; add the manual-marker tests.
+- `docs/check-links.sh:33` -- default roots.
+- `.github/workflows/docs.yml:51-65` -- `links` job to extend; triggers already include `deploy/**`.
+- `.gitignore:12,463` -- `*.env` ignored (so `.env.example` is not), any `build/` ignored; check new paths with `git check-ignore`.
+- `deploy/release/test.sh` -- test style to copy for `deploy/realm/test.sh`.
+
+## Tasks & Acceptance
+
+**Execution:**
+- `deploy/realm/prepare-realm.sh` -- the realm writer described in Always, with `--help`.
+- `deploy/realm/test.sh` -- the matrix rows for the script, in `deploy/release/test.sh` style, with `jq` assertions on the output.
+- `docs/deploy/README.md` -- the six-section guide (plus a short "Before you start" list: hardware for the server, a release tag, what costs money, and "Afterwards": upgrades, restore, verification).
+- `docs/build/apps.md` -- rewrite as the build-your-own-apps guide: prerequisites (Apple Developer Program, Firebase project, JDK 25, Android SDK, Xcode + XcodeGen), choosing ids, Android (config, debug install, signed release install, FCM), iOS (local xcconfig, team, push entitlement, install on a device), server push (`coldframe-push`, topic), troubleshooting (Unreachable notice = unconfigured URLs; sign-in redirect errors = scheme/realm mismatch), which version. Every bash block marked `ci:manual`.
+- `docs/check-commands.sh`, `docs/test.sh` -- the `ci:manual` marker and its tests (not run, listed, wrong-argument form fails like other unknown markers).
+- `deploy/compose/compose.yaml`, `deploy/compose/.env.example`, `deploy/compose/postgres-init.sql` (or `.sh`), `deploy/compose/README.md` -- the reference example and how to start it (`prepare-realm.sh --allow-http …`, `cp .env.example .env`, `docker compose up -d`).
+- `.github/workflows/docs.yml` -- the two new `links` steps. `docs/check-links.sh` -- add `deploy/compose/README.md` to default roots.
+- `deploy/charts/README.md` (prerequisite 3), `docs/operations/install.md` (realm sentence in step 2), `deploy/README.md:14`, `README.md`, `docs/quickstart.md:469` -- point to `docs/deploy/`, `prepare-realm.sh` and `deploy/compose/`.
+
+**Acceptance Criteria:**
+- Given `docs/deploy/README.md`, when read top to bottom, then the six steps appear in the AC order, each with the commands or the runbook link it needs, and it links `install.md`, `split-dns.md`, `restore.md` and `deploy/SECRETS.md`.
+- Given the build-your-own-apps section, when followed, then it states where the Server URL and Keycloak issuer are set for each platform at build time, how to use one's own APNs key and FCM project (app and Server side), and how to install on a phone.
+- Given `deploy/compose/`, when read, then both the README and `compose.yaml` say it is a reference example only, not the supported path.
+- Given a pull request, when the `Docs` workflow runs, then links resolve, `deploy/realm/test.sh` passes and `docker compose config` validates the example.
+
+## Spec Change Log
+
+## Review Triage Log
+
+### 2026-10-19 — Review pass
+- verdicts: 27 findings — high 0, medium 6, low 18, false 3, maybe-false 0
+- findings:
+ - `[low]` `[reject]` (blind) The deploy guide's command blocks are never checked by check-commands.sh — they need a cluster and a node, so CI could only list them; widening the checker's scope to docs/deploy is new scope, not a direct fix, and nothing there runs unattended.
+ - `[medium]` `[patch]` (blind) Nothing creates the `coldframe` namespace before the Secrets: deploy/SECRETS.md only sets NS=coldframe — section 3 now runs `kubectl create namespace coldframe` first.
+ - `[medium]` `[patch]` (blind) Section 2 runs bare `kubectl` on the node, but RKE2's bin dir is not on PATH — the block now exports `/var/lib/rancher/rke2/bin` (commented as for the node).
+ - `[low]` `[patch]` (blind) The stack install (GitRepo revision, bundles Ready) was buried in section 5 — it has its own `### Install the stack` heading; the six sections are unchanged.
+ - `[medium]` `[patch]` (blind) Nothing says to open inbound 443 on the node; firewalld blocks it on Fedora/RHEL and sign-in times out — section 2 has the firewall note with the firewall-cmd commands and RKE2's guidance link.
+ - `[low]` `[patch]` (blind) Compose step 1 hardcoded the realm origin, unrelated to COLDFRAME_HOST — it now uses `http://:3000` and says it must match .env exactly.
+ - `[low]` `[patch]` (blind) Compose start-up: temporal-namespace waits forever; Keycloak has no healthcheck — the wait is bounded (200 × 3 s, then exit 1). The Keycloak part is not a defect: the Server's JWT bearer and the web app fetch OIDC metadata lazily, and every long-running service has `restart: unless-stopped`.
+ - `[medium]` `[patch]` (blind) The empty-env CI check passes even if one variable loses its `:?` guard — replaced by deploy/compose/test.sh, which drops each guarded variable from .env.example in turn and asserts config fails naming it (17 tests), run in the Docs step.
+ - `[low]` `[patch]` (blind) realm test gaps — added the `--web-origin=`/`--out=` form, the empty `--web-origin=` error and a no-`localhost` assertion (28 tests). Port bounds, a missing jq and same in/out file were not added: same in/out works (jq reads before the write), and the others are unlikely and need new guards.
+ - `[low]` `[patch]` (blind) apps.md cited CoreConfig.kt and build.gradle.kts by line number — it now names `REDIRECT_SCHEME` and the `oidcRedirectScheme` placeholder.
+ - `[false]` `[reject]` (blind) apps.md contradicts CoreConfig.kt's "reversed domain of the application id" comment — the comment describes how the constant was named; the scheme is a constant independent of applicationId (build.gradle.kts sets the placeholder literally), so "does not have to match your ids" is true; the guide also names both realm fields.
+ - `[low]` `[patch]` (blind) The signed-release block assumes `$ANDROID_HOME` though the page offers `sdk.dir` — it now says ANDROID_HOME must be exported for that block.
+ - `[low]` `[patch]` (blind) The apps.md intro said every block needs a phone/Xcode/SDK/cluster, but `git checkout` does not — reworded to "most need".
+ - `[low]` `[patch]` (blind) Re-running `kubectl create configmap coldframe-realm` fails with AlreadyExists — the guide and install.md show the `--dry-run=client -o yaml | kubectl apply -f -` form and note it only matters before Keycloak's first start.
+ - `[low]` `[reject]` (blind) `--out coldframe-realm.json` lands in the repository root — it holds only placeholders and redirect URIs (no secret), shows up in `git status`, and install.md writes coldframe-values.yaml the same way; a root ignore rule is more than a direct fix.
+ - `[low]` `[patch]` (edge) temporal-namespace loops forever on a broken Temporal — same root cause and fix as the blind start-up row.
+ - `[low]` `[patch]` (edge) The realm origin can differ from COLDFRAME_HOST — same root cause and fix as the blind Compose origin row.
+ - `[low]` `[reject]` (edge) prepare-realm.sh accepts a port above 65535 or a default port — a typo here is unlikely and fails visibly at the first sign-in; rejecting it needs new range and normalisation guards.
+ - `[low]` `[reject]` (edge) A host of only dots or hyphens passes the origin regex — no one types that; tightening the regex for it is a guard for an unshown case.
+ - `[low]` `[patch]` (edge) Compose interpolates `$` in .env values — .env.example now says to use the openssl commands and single-quote any value containing `$`.
+ - `[medium]` `[patch]` (verification-gap) The Compose guard check notices only when every guard is lost — same root cause and fix as the blind CI-guard row.
+ - `[low]` `[patch]` (verification-gap) The `--opt=value` form of prepare-realm.sh is untested — same fix as the blind realm-test row.
+ - `[medium]` `[reject]` (intent) Under the outcome reading the story needs a human (domain, Cloudflare token, Apple/Firebase consoles, phones), but the diff records no awaiting-operator — the fix is spec bookkeeping; finalization sets status awaiting-operator with operator_actions.
+ - `[false]` `[reject]` (intent) The guide walks through Secrets and the realm more narrowly than "creating every Secret" and "Phase Two setup" — section 3 creates the namespace, names every Secret of deploy/SECRETS.md and links its creation commands; section 4 writes the realm and creates the ConfigMap, and states what Phase Two features the realm turns on, which the image and realm ship, so there is nothing further to configure.
+ - `[low]` `[reject]` (intent) The deploy guide's own blocks are not exercised — same as the first blind row.
+ - `[false]` `[reject]` (intent) The Compose env var names are only a manual check — the verification-gap layer compared them with the server, web and keycloak chart templates and the web config.ts: they match.
+ - `[low]` `[reject]` (intent) No Docs workflow run is shown — the PR run is that verification; listed under residual risks.
+
+## Design Notes
+
+**Why a realm script.** "Edit the realm for your hosts" is the step most likely to go wrong silently: a wrong redirect URI only shows up as a Keycloak error at the first sign-in. A jq rewrite of exactly two fields is small, testable in CI, and serves both the Fleet path and Compose.
+
+**Why `ci:manual`.** `docs/build/apps.md` sits under the command checker's default glob, so its blocks must carry a marker; reusing `ci:hardware` would wrongly suggest an espflash check.
+
+## Verification
+
+**Commands:**
+- `deploy/realm/test.sh` -- all pass.
+- `docs/test.sh` -- all pass (including the new marker tests).
+- `docs/check-links.sh` -- exit 0.
+- `docs/check-commands.sh --list` -- every block in `docs/build/*.md` marked; apps.md blocks show `manual`.
+- `docker compose -f deploy/compose/compose.yaml --env-file deploy/compose/.env.example config --quiet` -- exit 0.
+- `git check-ignore deploy/compose/.env.example docs/deploy/README.md deploy/realm/prepare-realm.sh` -- nothing ignored.
+
+**Manual checks (if no CLI):**
+- Every env var name in `compose.yaml` matches the chart templates / `deploy/images/README.md`.
+- Every Gradle property / xcconfig key in `apps.md` exists in the build files.
+
+## Auto Run Result
+
+Status: awaiting-operator
+
+**Summary.** An ordered adopter guide, `docs/deploy/README.md`, takes a maker from a domain to a first sign-in in six numbered sections: domain and DNS token; RKE2 and Fleet; Secrets; Keycloak realm and Phase Two; split DNS (with the stack install); first sign-in. It links the Epic 2 runbooks (`install.md`, `split-dns.md`, `restore.md`) and `deploy/SECRETS.md` by section instead of copying them. It adds what no runbook had: installing RKE2, kubectl on the node, the firewall, the namespace, writing the realm, and the first sign-in with the Owner and the permanent Keycloak admin. A new tested script, `deploy/realm/prepare-realm.sh`, writes the realm for the adopter's web origin. `docs/build/apps.md` is now the full build-your-own-apps guide: build-time Server URL and issuer for each platform, choosing ids and the fixed redirect scheme, Firebase and APNs setup on the app and Server side, debug and signed-release installs, and troubleshooting. `deploy/compose/` is a Compose example labelled reference-only in its README and in `compose.yaml`. The `Docs` workflow now tests the realm writer and validates the Compose file, including every required variable.
+
+**Files changed**
+- `docs/deploy/README.md`: the new six-section deploy guide.
+- `docs/build/apps.md`: the build-your-own-apps guide; every command block marked `ci:manual`.
+- `deploy/realm/prepare-realm.sh`, `deploy/realm/test.sh`: the realm writer and its 28 tests.
+- `deploy/compose/compose.yaml`, `.env.example`, `postgres-init.sh`, `README.md`, `.gitignore`, `test.sh`: the reference example, its settings template, the database init, the guide and the guard tests (17).
+- `docs/check-commands.sh`, `docs/test.sh`: the `ci:manual` marker and its tests.
+- `docs/check-links.sh`: `deploy/compose/README.md` added to the default roots.
+- `.github/workflows/docs.yml`: the realm-writer and Compose steps in the `links` job.
+- `README.md`, `deploy/README.md`, `deploy/charts/README.md`, `docs/operations/install.md`, `docs/quickstart.md`, `docs/build/README.md`: links to the guide, the script and the Compose example.
+
+**Review findings.** 27 findings: high 0, medium 6, low 18, false 3, maybe-false 0.
+- **Patched (13 root causes):** 4 medium (namespace, kubectl PATH, firewall 443, the per-variable Compose guard test) and 9 low (stack-install heading, Compose realm origin, bounded Temporal wait, realm test gaps, line-number citations, ANDROID_HOME, apps.md intro wording, ConfigMap replace, `$` in .env).
+- **Deferred:** none.
+- **Rejected,** each with its reason in the triage log: unchecked deploy-guide blocks (twice), the realm file in the repo root, port bounds and default ports, a dots-only host, the awaiting-operator bookkeeping (handled here), the unobserved CI run, and three false findings (CoreConfig comment, narrow Secrets/realm walk-through, Compose env names).
+
+**Follow-up review: recommended (true).** Four medium entries were patched. The unverified risk is the new guide steps nobody has run on a real host (the RKE2 install, kubectl PATH and firewalld commands, the namespace order) and the two new Docs steps, which have not run on GitHub yet.
+
+**Verification.**
+- `deploy/realm/test.sh`: 28 passed. `deploy/compose/test.sh`: 17 passed. `docs/test.sh`: 49 passed.
+- `docs/check-links.sh`: 497 links and paths in 27 files resolve.
+- `docs/check-commands.sh`: 19 blocks in 4 files pass (firmware builds and espflash subcommands); `--list` shows the five apps.md blocks as `ci:manual`.
+- `docker compose -f deploy/compose/compose.yaml --env-file deploy/compose/.env.example config --quiet`: exit 0; with an empty env file it fails naming each missing variable.
+- `git check-ignore`: none of the new files is ignored.
+- The implementer started postgres, the Temporal schema and namespace one-shots, Temporal and NATS from the Compose file locally with Podman: the four databases and the `coldframe` namespace were created.
+
+**Residual risks.**
+- No published release exists yet, so the Keycloak, migrations, Server and web services of the Compose example have never started.
+- The deploy guide and the apps guide have not been followed on real hardware, a real domain or real phones; that is Story 10.5 and the operator actions above.
+- The SELinux `chcon` hint in the Compose README is untested.
+- `shellcheck` and `actionlint` were not available locally; the new Docs steps have not run on GitHub yet.
diff --git a/deploy/README.md b/deploy/README.md
index 9040ee3..9407289 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -11,7 +11,7 @@ Everything needed to run Coldframe outside a developer machine.
| Epic 2, Story 2.5a | The Fleet bundles: a `fleet.yaml` in every chart folder, the operator bundles and the reference GitRepo in [`fleet/`](fleet), and the `rke2-traefik` bundle in [`rke2/`](rke2/README.md); the GitOps install runbook [`docs/operations/install.md`](../docs/operations/install.md). |
| Epic 2, Story 2.5b | The Fleet smoke [`fleet/smoke.sh`](fleet/smoke.sh): Fleet installs the bundles on a disposable cluster in dependency order, then an upgrade and three restarts lose no committed data |
| Epic 10, Story 10.1 | One release per tag `vX.Y.Z`: images, Hub and Node firmware, chart packages and `SHA256SUMS` on a GitHub Release, the wire gate and the dry run: see [`release/`](release) and the runbook [`docs/operations/release.md`](../docs/operations/release.md). [`charts/set-version.sh`](charts/set-version.sh) sets the charts to the version before tagging |
-| Epic 10 | The Compose file, as a reference example only |
+| Epic 10, Story 10.3 | The adopter deploy guide [`docs/deploy/`](../docs/deploy/README.md), the realm writer [`realm/prepare-realm.sh`](realm/prepare-realm.sh), and the Compose file in [`compose/`](compose/README.md), as a reference example only (not the supported deployment) |
No secret value is ever committed here. Charts reference Secrets by fixed names and keys, listed in
[`SECRETS.md`](SECRETS.md), and never create one (AD-15).
diff --git a/deploy/charts/README.md b/deploy/charts/README.md
index d741f78..a749695 100644
--- a/deploy/charts/README.md
+++ b/deploy/charts/README.md
@@ -78,7 +78,9 @@ In the namespace of the releases (the defaults assume one namespace):
database Secrets (each `username` equal to its role name) and `coldframe-backup-s3`.
2. An S3 bucket off the node for the backups, and its endpoint.
3. For Keycloak, a ConfigMap holding the `coldframe` realm file, with the redirect URIs of your
- host names (the realm in `aspire/keycloak/realms` is for localhost and is not packaged).
+ host names (the realm in `aspire/keycloak/realms` is for localhost and is not packaged): write
+ it with [`../realm/prepare-realm.sh`](../realm/prepare-realm.sh) `--web-origin https://`
+ ([`docs/deploy/`](../../docs/deploy/README.md#4-keycloak-realm-and-phase-two)).
4. A domain with its DNS zone at Cloudflare, the Secret `coldframe-dns01` holding an API token for
that zone, and split DNS for the three hosts
([`docs/operations/split-dns.md`](../../docs/operations/split-dns.md)).
diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example
new file mode 100644
index 0000000..00788de
--- /dev/null
+++ b/deploy/compose/.env.example
@@ -0,0 +1,48 @@
+# The settings of the Compose reference example (deploy/compose/README.md): REFERENCE EXAMPLE ONLY,
+# not the supported deployment. Copy this file to .env (git ignores it) and replace every value.
+# The values here are placeholders that only make `docker compose config` pass.
+#
+# Compose interpolates `$` in .env values: use the suggested openssl commands, and single-quote any
+# value that contains `$`. Never commit a filled .env.
+
+# The release to run, without the leading v (a tag vX.Y.Z publishes the images as X.Y.Z). There
+# is no default and no `latest`.
+COLDFRAME_VERSION=X.Y.Z
+
+# The LAN name or IP address of the Docker host. The browser and the containers both use it, so
+# the Keycloak issuer is the same string for both (web http://:3000, Keycloak :8180).
+COLDFRAME_HOST=192.168.1.10
+
+# PostgreSQL: the superuser, and the passwords of the roles coldframe, temporal and keycloak.
+# The coldframe password must not contain ';' (it goes into a connection string).
+POSTGRES_PASSWORD=replace-me-postgres
+COLDFRAME_DB_PASSWORD=replace-me-coldframe-db
+TEMPORAL_DB_PASSWORD=replace-me-temporal-db
+KEYCLOAK_DB_PASSWORD=replace-me-keycloak-db
+
+# The temporary admin of Keycloak's master realm, created on the first start.
+KEYCLOAK_ADMIN_USERNAME=admin
+KEYCLOAK_ADMIN_PASSWORD=replace-me-keycloak-admin
+
+# The secrets of the coldframe-web and coldframe-server clients, substituted into the realm at
+# the first import (openssl rand -hex 32).
+WEB_CLIENT_SECRET=replace-me-web-client-secret
+SERVER_CLIENT_SECRET=replace-me-server-client-secret
+
+# The SMTP server for registration and invitation emails. SMTP_USERNAME and SMTP_PASSWORD may stay
+# empty with SMTP_AUTH=false.
+SMTP_HOST=smtp.example.org
+SMTP_PORT=587
+SMTP_FROM=coldframe@example.org
+SMTP_USERNAME=
+SMTP_PASSWORD=
+SMTP_AUTH=true
+SMTP_STARTTLS=true
+SMTP_SSL=false
+
+# Device enrolment: the X25519 private key as PKCS#8 PEM, on one line with \n between the lines
+# and in double quotes (deploy/compose/README.md shows the command), and the key-encryption key
+# of the stored Device keys, at least 32 characters (openssl rand -base64 32). Record the KEK
+# outside this machine: without it no enrolled Device works.
+ENROLMENT_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n"
+DEVICE_KEK=replace-me-with-at-least-32-characters
diff --git a/deploy/compose/.gitignore b/deploy/compose/.gitignore
new file mode 100644
index 0000000..907baf2
--- /dev/null
+++ b/deploy/compose/.gitignore
@@ -0,0 +1 @@
+coldframe-realm.json
diff --git a/deploy/compose/README.md b/deploy/compose/README.md
new file mode 100644
index 0000000..e7ad2ab
--- /dev/null
+++ b/deploy/compose/README.md
@@ -0,0 +1,85 @@
+# Compose reference example
+
+> **Reference example only, not the supported deployment (NFR7).** The supported path is
+> single-node RKE2 with Fleet: [`docs/deploy/`](../../docs/deploy/README.md). Nothing here is
+> tested beyond `docker compose config`, and no upgrade, backup or restore path exists for it.
+
+[`compose.yaml`](compose.yaml) runs the released images the way the Helm charts in
+[`deploy/charts`](../charts/README.md) do, on one Docker host: PostgreSQL 18.6 with the databases
+`coldframe`, `temporal`, `temporal_visibility` and `keycloak` ([`postgres-init.sh`](postgres-init.sh)),
+NATS 2.15.0 with JetStream, the Temporal schema and namespace one-shots and Temporal Server 1.31.2,
+Phase Two Keycloak, the migration one-shot, the Server and the web app. It is useful to read how the
+pieces fit together, or to try the web app on a LAN before you set up the real thing.
+
+What it does not have:
+
+- **No TLS.** Everything is plain HTTP on the LAN: the Server trusts an `http://` issuer
+ (`Identity__RequireHttpsMetadata=false`), the web app talks to Keycloak over HTTP
+ (`KEYCLOAK_ALLOW_INSECURE_HTTP=true`) and its session cookie is not `Secure`. Never expose these
+ ports to the internet.
+- **No backups.** The data lives in the Docker volumes `postgres` and `nats`; `docker compose down -v`
+ deletes it.
+- **The web app only.** Release builds of the mobile apps need HTTPS with a public certificate, and
+ no push credentials are wired in ([`docs/build/apps.md`](../../docs/build/apps.md)).
+
+## Ports
+
+| Port on the Docker host | Service |
+| --- | --- |
+| 3000 | the web app: `http://:3000` |
+| 8080 | the Server |
+| 8180 | Keycloak: `http://:8180`, issuer `http://:8180/realms/coldframe` |
+
+`COLDFRAME_HOST` is one LAN name or IP address of the Docker host. The browser and the containers
+both use it, so the issuer in a token is the same string the Server and the web app expect; the
+containers must be able to reach the host's published ports under that name.
+
+## Start it
+
+From the repository root, at the release tag you run (`git checkout vX.Y.Z`), with Docker Compose
+v2.23 or later and `jq`:
+
+1. Write the realm for your origin. The origin must be exactly `http://:3000`,
+ with the same `COLDFRAME_HOST` as in `.env` (step 2); a mismatch only shows as a `redirect_uri`
+ error at the first sign-in, after the one-time import. `--allow-http` exists for this example
+ only:
+
+ ```sh
+ deploy/realm/prepare-realm.sh --allow-http --web-origin http://:3000 \
+ --out deploy/compose/coldframe-realm.json
+ ```
+
+2. Copy the settings and replace every value (git ignores `.env`; never commit it):
+
+ ```sh
+ cp deploy/compose/.env.example deploy/compose/.env
+ $EDITOR deploy/compose/.env
+ ```
+
+ `COLDFRAME_VERSION` is the release without the `v` (there is no default and no `latest`).
+ Generate the secrets with `openssl rand -hex 32` and the Device key-encryption key with
+ `openssl rand -base64 32`; record that key apart from this machine, since no enrolled Device works
+ without it. The enrolment key goes on one line with `\n` between its lines:
+
+ ```sh
+ printf 'ENROLMENT_PRIVATE_KEY_PEM="%s"\n' "$(openssl genpkey -algorithm X25519 | awk '{printf "%s\\n", $0}')"
+ ```
+
+3. Start it:
+
+ ```sh
+ docker compose -f deploy/compose/compose.yaml --env-file deploy/compose/.env up -d
+ docker compose -f deploy/compose/compose.yaml --env-file deploy/compose/.env ps -a
+ ```
+
+ `temporal-schema`, `temporal-namespace` and `migrations` run once and exit 0; the rest keep
+ running. On a host with SELinux (Fedora, RHEL) the containers cannot read the bind-mounted
+ `postgres-init.sh` and `coldframe-realm.json` until you relabel them
+ (`chcon -t container_file_t deploy/compose/postgres-init.sh deploy/compose/coldframe-realm.json`).
+
+4. Open `http://:3000`, Register, verify the email (the SMTP values of `.env`) and
+ create a Site, as in [section 6 of the deploy guide](../../docs/deploy/README.md#6-first-sign-in).
+
+The realm is imported on the first start only; to change it later use the Keycloak admin console
+(`http://:8180/admin`, the bootstrap admin of `.env`), or start over with
+`docker compose ... down -v`, which deletes every volume.
diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml
new file mode 100644
index 0000000..90280c3
--- /dev/null
+++ b/deploy/compose/compose.yaml
@@ -0,0 +1,237 @@
+# Coldframe with Docker Compose: a REFERENCE EXAMPLE ONLY, not the supported deployment (NFR7).
+# The supported path is single-node RKE2 with Fleet: docs/deploy/README.md.
+#
+# This file shows how the released images fit together, with the same topology and environment
+# variables as the Helm charts in deploy/charts. It has no TLS, no backups and no upgrade path, and
+# it serves the web app only: release builds of the mobile apps need HTTPS with a public
+# certificate. Read deploy/compose/README.md before you start it.
+#
+# Every secret and host comes from .env (copy .env.example; git ignores .env). The Keycloak realm
+# is the file ./coldframe-realm.json, written by deploy/realm/prepare-realm.sh --allow-http.
+name: coldframe
+
+services:
+ # PostgreSQL with the four databases of the charts' `database` release; postgres-init.sh creates
+ # the roles and databases on the first start (an empty volume) only.
+ postgres:
+ image: docker.io/library/postgres:18.6
+ restart: unless-stopped
+ environment:
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
+ COLDFRAME_DB_PASSWORD: ${COLDFRAME_DB_PASSWORD:?set COLDFRAME_DB_PASSWORD in .env}
+ TEMPORAL_DB_PASSWORD: ${TEMPORAL_DB_PASSWORD:?set TEMPORAL_DB_PASSWORD in .env}
+ KEYCLOAK_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?set KEYCLOAK_DB_PASSWORD in .env}
+ volumes:
+ - postgres:/var/lib/postgresql
+ - ./postgres-init.sh:/docker-entrypoint-initdb.d/10-coldframe.sh:ro
+ healthcheck:
+ # TCP only: the init scripts run on a socket-only server, so this waits for them.
+ test: ["CMD", "pg_isready", "--host", "127.0.0.1", "--username", "postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ # NATS with JetStream, as the `nats` chart starts it.
+ nats:
+ image: docker.io/library/nats:2.15.0
+ restart: unless-stopped
+ command: ["--port=4222", "--http_port=8222", "--jetstream", "--store_dir=/data"]
+ volumes:
+ - nats:/data
+
+ # The four temporal-sql-tool commands of the `temporal` chart's schema Job. Idempotent: runs on
+ # every `up` and exits.
+ temporal-schema:
+ image: docker.io/temporalio/admin-tools:1.31.2
+ restart: "no"
+ entrypoint: ["sh", "-ec"]
+ command:
+ - |
+ schema=/etc/temporal/schema/postgresql/v12
+ temporal-sql-tool --database "$${DATABASE}" setup-schema -v 0.0
+ temporal-sql-tool --database "$${DATABASE}" update-schema -d "$${schema}/temporal/versioned"
+ temporal-sql-tool --database "$${VISIBILITY_DATABASE}" setup-schema -v 0.0
+ temporal-sql-tool --database "$${VISIBILITY_DATABASE}" update-schema -d "$${schema}/visibility/versioned"
+ environment:
+ SQL_PLUGIN: postgres12
+ SQL_HOST: postgres
+ SQL_PORT: "5432"
+ SQL_USER: temporal
+ SQL_PASSWORD: ${TEMPORAL_DB_PASSWORD:?set TEMPORAL_DB_PASSWORD in .env}
+ DATABASE: temporal
+ VISIBILITY_DATABASE: temporal_visibility
+ depends_on:
+ postgres:
+ condition: service_healthy
+
+ # Temporal Server for the Keycloak event pipeline (frontend, history, matching and worker).
+ temporal:
+ image: docker.io/temporalio/server:1.31.2
+ restart: unless-stopped
+ environment:
+ DB: postgres12
+ POSTGRES_SEEDS: postgres
+ DB_PORT: "5432"
+ DBNAME: temporal
+ VISIBILITY_DBNAME: temporal_visibility
+ POSTGRES_USER: temporal
+ POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD:?set TEMPORAL_DB_PASSWORD in .env}
+ NUM_HISTORY_SHARDS: "4"
+ # The image's entrypoint sets the broadcast address to the container's own address.
+ BIND_ON_IP: 0.0.0.0
+ DYNAMIC_CONFIG_FILE_PATH: /etc/temporal/dynamicconfig/dynamicconfig.yaml
+ configs:
+ - source: temporal-dynamicconfig
+ target: /etc/temporal/dynamicconfig/dynamicconfig.yaml
+ depends_on:
+ temporal-schema:
+ condition: service_completed_successfully
+
+ # The `temporal` chart's namespace Job: creates the namespace `coldframe` when it is absent.
+ temporal-namespace:
+ image: docker.io/temporalio/admin-tools:1.31.2
+ restart: "no"
+ entrypoint: ["sh", "-ec"]
+ command:
+ - |
+ tries=0
+ until temporal operator cluster health >/dev/null 2>&1; do
+ tries=$$((tries + 1))
+ if [ "$${tries}" -ge 200 ]; then
+ echo "the Temporal frontend at $${TEMPORAL_ADDRESS} did not answer within 10 minutes" >&2
+ exit 1
+ fi
+ echo "waiting for the Temporal frontend at $${TEMPORAL_ADDRESS}"
+ sleep 3
+ done
+ if temporal operator namespace describe --namespace "$${NAMESPACE}" >/dev/null 2>&1; then
+ echo "namespace $${NAMESPACE} exists; left as it is"
+ else
+ temporal operator namespace create --namespace "$${NAMESPACE}" --retention "$${RETENTION}"
+ fi
+ environment:
+ TEMPORAL_ADDRESS: temporal:7233
+ NAMESPACE: coldframe
+ RETENTION: 72h
+ depends_on:
+ temporal:
+ condition: service_started
+
+ # Phase Two Keycloak with the Temporal event listener. The realm is imported on the first start
+ # only; a realm that exists is never re-imported.
+ keycloak:
+ image: ghcr.io/escendit/coldframe/keycloak:${COLDFRAME_VERSION:?set COLDFRAME_VERSION in .env, e.g. 1.2.3}
+ restart: unless-stopped
+ command: ["start", "--optimized", "--import-realm"]
+ environment:
+ KC_DB_URL_HOST: postgres
+ KC_DB_URL_PORT: "5432"
+ KC_DB_URL_DATABASE: keycloak
+ KC_DB_USERNAME: keycloak
+ KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?set KEYCLOAK_DB_PASSWORD in .env}
+ KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:?set KEYCLOAK_ADMIN_USERNAME in .env}
+ KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD in .env}
+ # Plain HTTP, no proxy: the one URL the browser and the containers both use.
+ KC_HTTP_ENABLED: "true"
+ KC_HOSTNAME: http://${COLDFRAME_HOST:?set COLDFRAME_HOST in .env}:8180
+ KC_SPI_EVENTS_LISTENER__TEMPORAL__TARGET_HOST: temporal:7233
+ KC_SPI_EVENTS_LISTENER__TEMPORAL__NAMESPACE: coldframe
+ # Substituted into the imported realm.
+ COLDFRAME_WEB_CLIENT_SECRET: ${WEB_CLIENT_SECRET:?set WEB_CLIENT_SECRET in .env}
+ COLDFRAME_SERVER_CLIENT_SECRET: ${SERVER_CLIENT_SECRET:?set SERVER_CLIENT_SECRET in .env}
+ COLDFRAME_SMTP_HOST: ${SMTP_HOST:?set SMTP_HOST in .env}
+ COLDFRAME_SMTP_PORT: ${SMTP_PORT:?set SMTP_PORT in .env}
+ COLDFRAME_SMTP_FROM: ${SMTP_FROM:?set SMTP_FROM in .env}
+ COLDFRAME_SMTP_USER: ${SMTP_USERNAME:-}
+ COLDFRAME_SMTP_PASSWORD: ${SMTP_PASSWORD:-}
+ COLDFRAME_SMTP_AUTH: ${SMTP_AUTH:-true}
+ COLDFRAME_SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
+ COLDFRAME_SMTP_SSL: ${SMTP_SSL:-false}
+ ports:
+ - "8180:8080"
+ volumes:
+ - type: bind
+ source: ./coldframe-realm.json
+ target: /opt/keycloak/data/import/coldframe-realm.json
+ read_only: true
+ bind:
+ create_host_path: false
+ depends_on:
+ postgres:
+ condition: service_healthy
+ temporal-namespace:
+ condition: service_completed_successfully
+
+ # The Server's migration Job: runs before the Server, on every `up`, and exits.
+ migrations:
+ image: ghcr.io/escendit/coldframe/migrations:${COLDFRAME_VERSION:?set COLDFRAME_VERSION in .env, e.g. 1.2.3}
+ restart: "no"
+ environment:
+ ConnectionStrings__coldframe: Host=postgres;Port=5432;Database=coldframe;Username=coldframe;Password=${COLDFRAME_DB_PASSWORD:?set COLDFRAME_DB_PASSWORD in .env}
+ depends_on:
+ postgres:
+ condition: service_healthy
+
+ # The Server: Orleans silo, Edge API and SignalR.
+ server:
+ image: ghcr.io/escendit/coldframe/server:${COLDFRAME_VERSION:?set COLDFRAME_VERSION in .env, e.g. 1.2.3}
+ restart: unless-stopped
+ environment:
+ ConnectionStrings__coldframe: Host=postgres;Port=5432;Database=coldframe;Username=coldframe;Password=${COLDFRAME_DB_PASSWORD:?set COLDFRAME_DB_PASSWORD in .env}
+ ConnectionStrings__nats: nats://nats:4222
+ # The issuer exactly as the browser sees it, over plain HTTP.
+ Identity__Authority: http://${COLDFRAME_HOST:?set COLDFRAME_HOST in .env}:8180/realms/coldframe
+ Identity__Audience: coldframe-server
+ Identity__RequireHttpsMetadata: "false"
+ Keycloak__BaseUrl: http://keycloak:8080
+ Keycloak__Realm: coldframe
+ Keycloak__ClientId: coldframe-server
+ Keycloak__ClientSecret: ${SERVER_CLIENT_SECRET:?set SERVER_CLIENT_SECRET in .env}
+ Keycloak__InvitationRedirectUri: http://${COLDFRAME_HOST:?set COLDFRAME_HOST in .env}:3000/garden
+ KeycloakEvents__TargetHost: temporal:7233
+ KeycloakEvents__Namespace: coldframe
+ KeycloakEvents__RealmId: coldframe
+ Enrolment__PrivateKeyPem: ${ENROLMENT_PRIVATE_KEY_PEM:?set ENROLMENT_PRIVATE_KEY_PEM in .env}
+ Enrolment__DeviceKeyEncryptionKey: ${DEVICE_KEK:?set DEVICE_KEK in .env}
+ ASPNETCORE_HTTP_PORTS: "8080"
+ ports:
+ - "8080:8080"
+ depends_on:
+ migrations:
+ condition: service_completed_successfully
+ nats:
+ condition: service_started
+ temporal-namespace:
+ condition: service_completed_successfully
+ keycloak:
+ condition: service_started
+
+ # The web app (backend-for-frontend).
+ web:
+ image: ghcr.io/escendit/coldframe/web:${COLDFRAME_VERSION:?set COLDFRAME_VERSION in .env, e.g. 1.2.3}
+ restart: unless-stopped
+ environment:
+ PORT: "3000"
+ COLDFRAME_SERVER_URL: http://server:8080
+ KEYCLOAK_ISSUER: http://${COLDFRAME_HOST:?set COLDFRAME_HOST in .env}:8180/realms/coldframe
+ KEYCLOAK_CLIENT_ID: coldframe-web
+ KEYCLOAK_CLIENT_SECRET: ${WEB_CLIENT_SECRET:?set WEB_CLIENT_SECRET in .env}
+ KEYCLOAK_ALLOW_INSECURE_HTTP: "true"
+ SESSION_COOKIE_SECURE: "false"
+ ORIGIN: http://${COLDFRAME_HOST:?set COLDFRAME_HOST in .env}:3000
+ ports:
+ - "3000:3000"
+ depends_on:
+ server:
+ condition: service_started
+ keycloak:
+ condition: service_started
+
+configs:
+ # The dynamic configuration file the Temporal server requires; empty, as in the chart.
+ temporal-dynamicconfig:
+ content: "{}\n"
+
+volumes:
+ postgres:
+ nats:
diff --git a/deploy/compose/postgres-init.sh b/deploy/compose/postgres-init.sh
new file mode 100755
index 0000000..e18271c
--- /dev/null
+++ b/deploy/compose/postgres-init.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+# The roles and databases of the charts' `database` release, for the Compose reference example
+# (deploy/compose/README.md). The postgres image runs this once, on the first start with an empty
+# volume; a changed password in .env afterwards needs ALTER ROLE by hand.
+#
+# coldframe owns coldframe; temporal owns temporal and temporal_visibility; keycloak owns keycloak.
+set -eu
+
+psql --variable ON_ERROR_STOP=1 --username "${POSTGRES_USER:-postgres}" --dbname postgres \
+ --set coldframe_password="${COLDFRAME_DB_PASSWORD}" \
+ --set temporal_password="${TEMPORAL_DB_PASSWORD}" \
+ --set keycloak_password="${KEYCLOAK_DB_PASSWORD}" <<'SQL'
+CREATE ROLE coldframe LOGIN PASSWORD :'coldframe_password';
+CREATE DATABASE coldframe OWNER coldframe;
+CREATE ROLE temporal LOGIN PASSWORD :'temporal_password';
+CREATE DATABASE temporal OWNER temporal;
+CREATE DATABASE temporal_visibility OWNER temporal;
+CREATE ROLE keycloak LOGIN PASSWORD :'keycloak_password';
+CREATE DATABASE keycloak OWNER keycloak;
+SQL
diff --git a/deploy/compose/test.sh b/deploy/compose/test.sh
new file mode 100755
index 0000000..8d4fc43
--- /dev/null
+++ b/deploy/compose/test.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Plain-bash tests for the Compose reference example (Story 10.3): `docker compose config` passes
+# with .env.example, and fails naming the variable when any one `${NAME:?…}` guard of compose.yaml
+# is left out of the settings. Needs Docker Compose; starts nothing. Run from anywhere:
+#
+# deploy/compose/test.sh
+set -uo pipefail
+
+here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+work=$(mktemp -d)
+trap 'rm -rf "${work}"' EXIT
+failures=0
+passes=0
+
+pass() {
+ passes=$((passes + 1))
+ echo "ok - $1"
+}
+
+failed() {
+ failures=$((failures + 1))
+ echo "FAIL - $1" >&2
+ shift
+ for line in "$@"; do
+ echo " ${line}" >&2
+ done
+}
+
+compose=${here}/compose.yaml
+example=${here}/.env.example
+
+# config [-u NAME]: docker compose config, without NAME in the process environment.
+config() {
+ local env_file=$1
+ shift
+ env "$@" docker compose -f "${compose}" --env-file "${env_file}" config --quiet
+}
+
+if err=$(config "${example}" 2>&1); then
+ pass ".env.example passes docker compose config"
+else
+ failed ".env.example passes docker compose config" "${err}"
+fi
+
+mapfile -t guarded < <(grep -oE '\$\{[A-Z_][A-Z0-9_]*:\?' "${compose}" | sed -E 's/^\$\{//; s/:\?$//' | sort -u)
+if [[ ${#guarded[@]} -gt 0 ]]; then
+ pass "compose.yaml has ${#guarded[@]} required variables"
+else
+ failed "compose.yaml has required variables" "no \${NAME:? guard found"
+fi
+
+for name in "${guarded[@]}"; do
+ grep -v "^${name}=" "${example}" > "${work}/without.env"
+ if ! grep -q "^${name}=" "${example}"; then
+ failed "${name} is in .env.example" "compose.yaml requires it, .env.example does not set it"
+ continue
+ fi
+ if err=$(config "${work}/without.env" -u "${name}" 2>&1); then
+ failed "a missing ${name} fails config" "config passed without ${name}"
+ elif [[ ${err} == *"${name}"* ]]; then
+ pass "a missing ${name} fails config naming it"
+ else
+ failed "a missing ${name} fails config naming it" "${err}"
+ fi
+done
+
+echo
+echo "${passes} passed, ${failures} failed"
+[[ ${failures} -eq 0 ]]
diff --git a/deploy/realm/prepare-realm.sh b/deploy/realm/prepare-realm.sh
new file mode 100755
index 0000000..fea6bad
--- /dev/null
+++ b/deploy/realm/prepare-realm.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+# Writes the `coldframe` realm file for your hosts (Story 10.3).
+#
+# deploy/realm/prepare-realm.sh --web-origin https:// [--in ] [--out ] [--allow-http]
+#
+# Reads the realm of the local stack (aspire/keycloak/realms/coldframe-realm.json, or --in) and
+# sets the two redirect fields of the `coldframe-web` client to the web app's public origin:
+#
+# redirectUris [/.oidc/signin/callback]
+# attributes."post.logout.redirect.uris" /.oidc/signout/callback
+#
+# Everything else stays as it is: the ${COLDFRAME_*} placeholders (Keycloak substitutes them from
+# the Secrets at the first import), the `coldframe-mobile` client and its com.escendit.coldframe
+# scheme, and the realm settings. The result goes to --out, or to stdout.
+#
+# The origin is a scheme and a host (and an optional port), without a path; a trailing slash is
+# dropped. It must be https://, unless --allow-http is given, which is for the Compose reference
+# example only (deploy/compose/README.md).
+#
+# Exit 0 on success, 1 when jq fails or the input holds no coldframe-web client, 2 on bad usage.
+set -euo pipefail
+
+here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+repo=$(cd "${here}/../.." && pwd)
+
+usage() {
+ echo "usage: prepare-realm.sh --web-origin https:// [--in ] [--out ] [--allow-http]" >&2
+ [[ $# -gt 0 ]] && echo "prepare-realm.sh: $1" >&2
+ exit 2
+}
+
+input=${repo}/aspire/keycloak/realms/coldframe-realm.json
+output=""
+origin=""
+allow_http=false
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ -h | --help)
+ sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ --web-origin | --in | --out)
+ [[ $# -ge 2 && -n $2 ]] || usage "$1 needs a value"
+ case $1 in
+ --web-origin) origin=$2 ;;
+ --in) input=$2 ;;
+ --out) output=$2 ;;
+ esac
+ shift 2
+ ;;
+ --web-origin=* | --in=* | --out=*)
+ value=${1#*=}
+ [[ -n ${value} ]] || usage "${1%%=*} needs a value"
+ case $1 in
+ --web-origin=*) origin=${value} ;;
+ --in=*) input=${value} ;;
+ --out=*) output=${value} ;;
+ esac
+ shift
+ ;;
+ --allow-http)
+ allow_http=true
+ shift
+ ;;
+ *) usage "unknown argument '$1'" ;;
+ esac
+done
+
+[[ -n ${origin} ]] || usage "--web-origin is required"
+origin=${origin%/}
+if [[ ! ${origin} =~ ^(https?)://[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]]; then
+ usage "'${origin}' is not an origin: a scheme and a host (and port), without a path, such as https://coldframe.example.org"
+fi
+if [[ ${BASH_REMATCH[1]} == http && ${allow_http} != true ]]; then
+ usage "'${origin}' is plain http: the deployment needs https://. --allow-http is for the Compose reference example only"
+fi
+[[ -f ${input} ]] || usage "the input realm file '${input}' does not exist"
+
+command -v jq > /dev/null || {
+ echo "prepare-realm.sh: jq is required" >&2
+ exit 1
+}
+
+if ! jq -e '[.clients[]? | select(.clientId == "coldframe-web")] | length == 1' "${input}" > /dev/null 2>&1; then
+ echo "prepare-realm.sh: '${input}' is not a realm file with exactly one coldframe-web client" >&2
+ exit 1
+fi
+
+realm=$(jq --indent 2 --arg origin "${origin}" '
+ .clients |= map(
+ if .clientId == "coldframe-web" then
+ .redirectUris = [$origin + "/.oidc/signin/callback"]
+ | .attributes["post.logout.redirect.uris"] = ($origin + "/.oidc/signout/callback")
+ else . end)
+' "${input}")
+
+if [[ -n ${output} ]]; then
+ printf '%s\n' "${realm}" > "${output}"
+else
+ printf '%s\n' "${realm}"
+fi
diff --git a/deploy/realm/test.sh b/deploy/realm/test.sh
new file mode 100755
index 0000000..2b1ef9c
--- /dev/null
+++ b/deploy/realm/test.sh
@@ -0,0 +1,148 @@
+#!/usr/bin/env bash
+# Plain-bash tests for the realm writer (Story 10.3): prepare-realm.sh against the realm of the
+# local stack, with jq assertions on its output. Needs jq. Run from anywhere:
+#
+# deploy/realm/test.sh
+# The jq filters and placeholders are literal.
+# shellcheck disable=SC2016
+set -uo pipefail
+
+here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+repo=$(cd "${here}/../.." && pwd)
+work=$(mktemp -d)
+trap 'rm -rf "${work}"' EXIT
+failures=0
+passes=0
+
+pass() {
+ passes=$((passes + 1))
+ echo "ok - $1"
+}
+
+failed() {
+ failures=$((failures + 1))
+ echo "FAIL - $1" >&2
+ shift
+ for line in "$@"; do
+ echo " ${line}" >&2
+ done
+}
+
+# expect --
+# The expected stdout and stderr are substrings, "" for none at all, or - for anything.
+expect() {
+ local name=$1 want_status=$2 want_out=$3 want_err=$4
+ shift 5
+ local out err status
+ err=$(mktemp)
+ out=$("$@" 2>"${err}")
+ status=$?
+ local err_text
+ err_text=$(cat "${err}")
+ rm -f "${err}"
+
+ if [[ ${status} -ne ${want_status} ]]; then
+ failed "${name}" "exit ${status}, expected ${want_status}" "stdout: ${out}" "stderr: ${err_text}"
+ elif [[ -z ${want_out} && -n ${out} ]]; then
+ failed "${name}" "stdout '${out}', expected none"
+ elif [[ -n ${want_out} && ${want_out} != "-" && ${out} != *"${want_out}"* ]]; then
+ failed "${name}" "stdout '${out}' does not contain '${want_out}'"
+ elif [[ -z ${want_err} && -n ${err_text} ]]; then
+ failed "${name}" "stderr '${err_text}', expected none"
+ elif [[ -n ${want_err} && ${want_err} != "-" && ${err_text} != *"${want_err}"* ]]; then
+ failed "${name}" "stderr '${err_text}' does not contain '${want_err}'"
+ else
+ pass "${name}"
+ fi
+}
+
+# check : the filter is true on the file.
+check() {
+ if jq -e "$3" "$2" > /dev/null 2>&1; then
+ pass "$1"
+ else
+ failed "$1" "jq '$3' is not true on $2"
+ fi
+}
+
+prepare=${here}/prepare-realm.sh
+source_realm=${repo}/aspire/keycloak/realms/coldframe-realm.json
+web='.clients[] | select(.clientId == "coldframe-web")'
+
+echo "# prepare-realm.sh"
+domain=${work}/domain.json
+expect "a realm for a domain" 0 "" "" \
+ -- "${prepare}" --web-origin https://coldframe.example.org --out "${domain}"
+check "the output is a realm" "${domain}" '.realm == "coldframe"'
+check "the web sign-in redirect is the origin's callback" "${domain}" \
+ "${web} | .redirectUris == [\"https://coldframe.example.org/.oidc/signin/callback\"]"
+check "the web post-logout redirect is the origin's signout callback" "${domain}" \
+ "${web} | .attributes[\"post.logout.redirect.uris\"] == \"https://coldframe.example.org/.oidc/signout/callback\""
+check "the web client keeps its secret placeholder" "${domain}" \
+ "${web} | .secret == \"\${COLDFRAME_WEB_CLIENT_SECRET}\""
+check "the SMTP placeholders stay" "${domain}" \
+ '.smtpServer.host == "${COLDFRAME_SMTP_HOST}" and .smtpServer.password == "${COLDFRAME_SMTP_PASSWORD:}"'
+# Everything but the two web fields equals the source.
+if diff <(jq -S "del(.clients[] | select(.clientId == \"coldframe-web\") | .redirectUris, .attributes[\"post.logout.redirect.uris\"])" "${source_realm}") \
+ <(jq -S "del(.clients[] | select(.clientId == \"coldframe-web\") | .redirectUris, .attributes[\"post.logout.redirect.uris\"])" "${domain}") > /dev/null; then
+ pass "nothing else changes: the mobile and server clients, placeholders and realm settings"
+else
+ failed "nothing else changes: the mobile and server clients, placeholders and realm settings" \
+ "$(diff <(jq -S . "${source_realm}") <(jq -S . "${domain}") | head -20)"
+fi
+check "the mobile client keeps its scheme" "${domain}" \
+ '.clients[] | select(.clientId == "coldframe-mobile") | .redirectUris == ["com.escendit.coldframe:/signin/callback"]'
+
+if ! grep -q localhost "${domain}"; then
+ pass "no localhost is left in the output"
+else
+ failed "no localhost is left in the output" "$(grep -n localhost "${domain}")"
+fi
+
+equals=${work}/equals.json
+expect "the --web-origin=… and --out=… form" 0 "" "" \
+ -- "${prepare}" --web-origin=https://eq.example.org --out="${equals}"
+check "the =-form sets the redirect URIs" "${equals}" \
+ "${web} | .redirectUris == [\"https://eq.example.org/.oidc/signin/callback\"]"
+expect "an empty --web-origin= is refused" 2 "" "--web-origin needs a value" -- "${prepare}" --web-origin=
+
+slash=${work}/slash.json
+expect "a trailing slash" 0 "" "" -- "${prepare}" --web-origin https://x.example.org/ --out "${slash}"
+check "a trailing slash is dropped" "${slash}" \
+ "${web} | .redirectUris == [\"https://x.example.org/.oidc/signin/callback\"] and .attributes[\"post.logout.redirect.uris\"] == \"https://x.example.org/.oidc/signout/callback\""
+
+expect "plain http is refused" 2 "" "--allow-http is for the Compose reference example only" \
+ -- "${prepare}" --web-origin http://192.168.1.10:3000
+http=${work}/http.json
+expect "plain http with --allow-http" 0 "" "" \
+ -- "${prepare}" --allow-http --web-origin http://192.168.1.10:3000 --out "${http}"
+check "--allow-http uses that origin" "${http}" \
+ "${web} | .redirectUris == [\"http://192.168.1.10:3000/.oidc/signin/callback\"] and .attributes[\"post.logout.redirect.uris\"] == \"http://192.168.1.10:3000/.oidc/signout/callback\""
+
+expect "a missing origin is refused" 2 "" "usage:" -- "${prepare}"
+expect "an origin with a path is refused" 2 "" "usage:" -- "${prepare}" --web-origin https://x.org/app
+expect "an origin without a scheme is refused" 2 "" "usage:" -- "${prepare}" --web-origin x.org
+expect "an unknown argument is refused" 2 "" "usage:" -- "${prepare}" --web-origin https://x.org --nope
+expect "a missing input names the file" 2 "" "nope.json" \
+ -- "${prepare}" --web-origin https://x.org --in "${work}/nope.json"
+
+printf '{"realm": "coldframe", "clients": []}\n' > "${work}/no-web.json"
+expect "an input without the web client fails" 1 "" "exactly one coldframe-web client" \
+ -- "${prepare}" --web-origin https://x.org --in "${work}/no-web.json"
+
+expect "without --out the realm goes to stdout" 0 '"https://coldframe.example.org/.oidc/signin/callback"' "" \
+ -- "${prepare}" --web-origin https://coldframe.example.org
+stdout_realm=${work}/stdout.json
+"${prepare}" --web-origin https://coldframe.example.org > "${stdout_realm}"
+check "stdout is valid JSON" "${stdout_realm}" '.realm == "coldframe"'
+
+copy=${work}/copy.json
+cp "${source_realm}" "${copy}"
+expect "--in reads another file" 0 "" "" -- "${prepare}" --web-origin https://x.org --in "${copy}" --out "${work}/in.json"
+check "--in output has the origin" "${work}/in.json" "${web} | .redirectUris == [\"https://x.org/.oidc/signin/callback\"]"
+
+expect "--help prints the usage" 0 "--web-origin" "" -- "${prepare}" --help
+
+echo
+echo "${passes} passed, ${failures} failed"
+[[ ${failures} -eq 0 ]]
diff --git a/docs/build/README.md b/docs/build/README.md
index ffbcd4a..a3624b0 100644
--- a/docs/build/README.md
+++ b/docs/build/README.md
@@ -39,7 +39,8 @@ The same page, for the Node. Its setup code appears when you long-press its butt
## 6. Add them from the app
Build the iOS or Android app for your own Server: [apps.md](apps.md). The Server must be running
-first (the developer [quickstart](../quickstart.md) runs one locally). Then, in the app:
+first: the [deploy guide](../deploy/README.md) sets it up on your home server (the developer
+[quickstart](../quickstart.md) runs one locally). Then, in the app:
1. **Add a Hub.** The app finds the unprovisioned Hub over BLE, asks for its setup code, and sends it
your Wi-Fi network (2.4 GHz, WPA2 or mixed WPA2/WPA3) and your Server's address.
diff --git a/docs/build/apps.md b/docs/build/apps.md
index f0ee08e..da7f100 100644
--- a/docs/build/apps.md
+++ b/docs/build/apps.md
@@ -1,39 +1,233 @@
# Build your own apps
Coldframe publishes no mobile store binaries. Each adopter builds the iOS and Android apps from
-source for their own Server, with their own push credentials. This page is a short guide; Story 10.3
-expands it.
+source, for their own deployment and with their own push credentials. The Server URL and the
+Keycloak issuer are fixed at build time: the apps have no field to enter or change them, so every
+build is for one deployment.
This page is step 6 of the [build guide](README.md). The Node and Hub hardware is in
-[`hardware/`](../../hardware/README.md).
+[`hardware/`](../../hardware/README.md); the deployment the apps talk to is set up with the
+[deploy guide](../deploy/README.md).
+
+CI does not run the command blocks here (`` in the source): most need a phone,
+Xcode, the Android SDK or a cluster.
## What you need
-- **Your own push credentials.** iOS pushes go through the Apple Push Notification service with
- your APNs key: the Apple Developer Program, an App ID with the Push Notifications capability, and
- the same key on the Server. Android pushes go through your own Firebase Cloud Messaging project.
- No key, team ID, provisioning profile or `google-services.json` belongs in this repository.
-- **The URLs of your deployment.** The Server URL and the Keycloak issuer
- (`https:///realms/coldframe`) are build-time configuration. The apps have no field to
- enter or change them, so every build is for one deployment. An unconfigured build starts and shows
- the Unreachable notice.
+- **Your deployment**, from the [deploy guide](../deploy/README.md), and its two URLs: the Server
+ `https://api.` and the Keycloak issuer `https://auth./realms/coldframe`. Release
+ builds trust public certificates only, which is what the deployment has.
+- **For Android**: JDK 25 and the Android SDK, a phone with USB debugging on, and `adb`. For
+ pushes, a **Firebase project** (free).
+- **For iOS**: a Mac with Xcode 26 and [XcodeGen](https://github.com/yonaskolb/XcodeGen), JDK 25
+ and the Android SDK (the build runs Gradle to build the shared Kotlin core, and Gradle configures
+ every project), and an iPhone. For pushes, membership of the **Apple Developer Program** (paid,
+ yearly); a free personal team can install the app on your own iPhone, without pushes.
+- **No credential in the repository.** No key, team ID, provisioning profile, keystore or
+ `google-services.json` belongs in it; the files below that hold your values are git-ignored or
+ live outside the repository.
-## iOS
+Point Gradle at the Android SDK once, in `local.properties` in the repository root (git ignores it)
+or through `ANDROID_HOME`, as in the [quickstart](../quickstart.md#run-the-android-app).
+
+## Which version
+
+Build the apps from the same release tag as the Server you run
+([`docs/operations/release.md`](../operations/release.md)), and rebuild them after every upgrade:
-Put your values in `apps/swift/ios/Config/Coldframe.local.xcconfig` (not committed), next to
-[`Coldframe.xcconfig`](../../apps/swift/ios/Config/Coldframe.xcconfig), which documents every key:
-`COLDFRAME_SERVER_URL`, `COLDFRAME_KEYCLOAK_ISSUER`, and for pushes `COLDFRAME_PUSH = YES` and your
-`DEVELOPMENT_TEAM`. Building, signing and push setup: [`apps/swift/README.md`](../../apps/swift/README.md).
+
+
+```bash
+git checkout vX.Y.Z
+```
+
+## Choose your ids
+
+| What | Default | Where | May you change it? |
+| --- | --- | --- | --- |
+| Android application ID | `com.escendit.coldframe` | `applicationId` in [`apps/kt/android/build.gradle.kts`](../../apps/kt/android/build.gradle.kts) | Yes, alone. The Firebase Android app must use the application ID you build. |
+| iOS bundle ID | `com.escendit.coldframe` | `PRODUCT_BUNDLE_IDENTIFIER` in [`apps/swift/ios/project.yml`](../../apps/swift/ios/project.yml) | Yes, alone. Your App ID and the Server's `push.apns.topic` must equal it. |
+| OIDC redirect scheme | `com.escendit.coldframe` | `REDIRECT_SCHEME` in `CoreConfig.kt`, the `oidcRedirectScheme` manifest placeholder in `build.gradle.kts` and the `coldframe-mobile` client of the realm | Keep it, or change all three together. |
+
+An Apple bundle ID is unique across all Apple developers, so `com.escendit.coldframe` may already
+be taken for your team: pick your own reverse domain, for example `org.example.coldframe`. The
+redirect scheme is only the app's sign-in callback; it does not have to match your ids. It is a
+constant in [`CoreConfig.kt`](../../packages/kt/core/src/commonMain/kotlin/com/escendit/coldframe/core/signin/CoreConfig.kt)
+(both apps), the `oidcRedirectScheme` manifest placeholder of the Android build, and the
+`redirectUris` and `post.logout.redirect.uris` of the `coldframe-mobile` client in the realm
+([`aspire/keycloak/realms/coldframe-realm.json`](../../aspire/keycloak/realms/coldframe-realm.json)).
+Change one without the others and sign-in fails with a redirect error.
## Android
-Pass the configuration as Gradle properties, on the command line or in `~/.gradle/gradle.properties`:
-`coldframe.serverUrl`, `coldframe.keycloakIssuer`, and for pushes `coldframe.firebaseProjectId`,
-`coldframe.firebaseApplicationId`, `coldframe.firebaseApiKey` and `coldframe.firebaseSenderId`
-(see [`apps/kt/android/build.gradle.kts`](../../apps/kt/android/build.gradle.kts)). Building and the
-Firebase setup: [`apps/kt/README.md`](../../apps/kt/README.md).
+### Configure the build
-## Which version
+The configuration is a set of Gradle properties (`coldframeProperty` in
+[`build.gradle.kts`](../../apps/kt/android/build.gradle.kts)). Put them in
+`~/.gradle/gradle.properties`, outside the repository, or pass each as `-P=`:
-Build the apps from the same release tag as the Server you run
-([`docs/operations/release.md`](../operations/release.md)).
+```properties
+coldframe.serverUrl=https://api.coldframe.example.org
+coldframe.keycloakIssuer=https://auth.coldframe.example.org/realms/coldframe
+# The public client of the realm; this is the default.
+coldframe.keycloakClientId=coldframe-mobile
+# Push (optional): the Android app of your Firebase project.
+coldframe.firebaseProjectId=
+coldframe.firebaseApplicationId=
+coldframe.firebaseApiKey=
+coldframe.firebaseSenderId=
+```
+
+Unset values fall back to never-resolvable `.invalid` addresses, so a build without them shows
+the Unreachable notice.
+
+### Push with your Firebase project
+
+There is no `google-services.json` and no google-services plugin: the four `coldframe.firebase*`
+properties carry the same values, and the app starts Firebase from them by hand. Without all four
+it starts no Firebase and registers no device; everything else works.
+
+1. In the [Firebase console](https://console.firebase.google.com), create a project and add an
+ **Android app** whose package name is the application ID you build (`com.escendit.coldframe`
+ unless you changed it).
+2. From Project settings → General take the **Project ID**, the Android app's **App ID** and the
+ **Web API key**; from Project settings → Cloud Messaging the **Sender ID**. Those are the four
+ properties above.
+3. For the Server: Project settings → Service accounts → generate a private key for a service
+ account with the role *Firebase Cloud Messaging API Admin*. That JSON file goes into the
+ Secret `coldframe-push` ([Server push](#server-push)).
+
+### Install a debug build (the quick path)
+
+With the phone connected by USB and USB debugging allowed:
+
+
+
+```bash
+./gradlew :android:installDebug
+```
+
+### Install a signed release build
+
+The release build carries no signing configuration, so you sign it with your own key after the
+build, with `zipalign` and `apksigner` from the Android SDK build-tools. Create the keystore once,
+**outside the repository**, and keep it and its password safe: an update must be signed with the
+same key.
+
+
+
+```bash
+keytool -genkeypair -v -keystore ~/keys/coldframe-release.jks -alias coldframe \
+ -keyalg RSA -keysize 4096 -validity 10000
+```
+
+Then build, align, sign and install. This block needs `ANDROID_HOME` exported to the SDK path, also
+when Gradle finds the SDK through `sdk.dir` in `local.properties`; `` is a folder in
+`$ANDROID_HOME/build-tools`:
+
+
+
+```bash
+./gradlew :android:assembleRelease
+BT="$ANDROID_HOME/build-tools/"
+"$BT/zipalign" -p -f 4 apps/kt/android/build/outputs/apk/release/android-release-unsigned.apk coldframe-aligned.apk
+"$BT/apksigner" sign --ks ~/keys/coldframe-release.jks --ks-key-alias coldframe --out coldframe.apk coldframe-aligned.apk
+"$BT/apksigner" verify coldframe.apk
+adb install coldframe.apk
+```
+
+A phone that has the debug build refuses the release build (another signature): uninstall the
+app first (`adb uninstall com.escendit.coldframe`, or your application ID).
+
+## iOS
+
+### Configure the build
+
+Put your values in `apps/swift/ios/Config/Coldframe.local.xcconfig` (git ignores it), next to
+[`Coldframe.xcconfig`](../../apps/swift/ios/Config/Coldframe.xcconfig), which documents every key.
+`//` starts a comment in an xcconfig, so write every URL as `https:/$()/host`:
+
+```text
+COLDFRAME_SERVER_URL = https:/$()/api.coldframe.example.org
+COLDFRAME_KEYCLOAK_ISSUER = https:/$()/auth.coldframe.example.org/realms/coldframe
+// The public client of the realm; this is the default.
+COLDFRAME_KEYCLOAK_CLIENT_ID = coldframe-mobile
+// Signing and push.
+DEVELOPMENT_TEAM =
+COLDFRAME_PUSH = YES
+```
+
+Empty values fall back to never-resolvable defaults, so a build without them shows the Unreachable
+notice. If you chose your own bundle ID, change `PRODUCT_BUNDLE_IDENTIFIER` in
+[`project.yml`](../../apps/swift/ios/project.yml) locally.
+
+### Push with your APNs key
+
+- **The entitlement.** `COLDFRAME_PUSH = YES` signs the app with
+ [`Config/Coldframe.entitlements`](../../apps/swift/ios/Config/Coldframe.entitlements), whose
+ `aps-environment` is `development` (APNs sandbox) for Debug and `production` for Release. The
+ team must be in the Apple Developer Program, and the App ID of your bundle ID needs the Push
+ Notifications capability; automatic signing adds it. A Release build signed with a development
+ profile needs `COLDFRAME_APS_ENVIRONMENT[config=Release] = development`. With `COLDFRAME_PUSH = NO`
+ (the default) the app builds with any team, gets no device token and registers nothing.
+- **The key, for the Server.** In the Apple Developer account, Certificates, Identifiers & Profiles
+ → Keys, create a key with the Apple Push Notifications service. Download the `.p8` (only once),
+ and note its Key ID and your Team ID. They go into the Secret `coldframe-push`
+ ([Server push](#server-push)).
+
+### Install on an iPhone
+
+
+
+```bash
+cd apps/swift/ios
+xcodegen generate
+open Coldframe.xcodeproj
+```
+
+In Xcode, connect the iPhone (with Developer Mode on), choose it as the run destination and Run.
+The build first runs `./gradlew :core:embedAndSignAppleFrameworkForXcode`, which needs JDK 25 and
+the Android SDK. Run the Debug configuration for the APNs sandbox, or archive the Release
+configuration for production pushes. More on the iOS project: [`apps/swift/README.md`](../../apps/swift/README.md).
+
+## Server push
+
+The Server sends a push only with the credentials of each provider, from the optional Secret
+`coldframe-push`: `apns-key.p8`, `apns-key-id` and `apns-team-id` for APNs, and
+`fcm-service-account.json` for FCM, of the providers you use
+([`deploy/SECRETS.md`](../../deploy/SECRETS.md#push-notifications)). Then enable the providers
+under the `server` key of your site values, with `push.apns.topic` equal to your iOS bundle ID:
+
+```yaml
+ server: |
+ identity:
+ authority: https://auth.coldframe.example.org/realms/coldframe
+ keycloak:
+ invitationRedirectUri: https://coldframe.example.org/garden
+ push:
+ apns:
+ enabled: true
+ topic: com.escendit.coldframe
+ fcm:
+ enabled: true
+```
+
+Apply the values and redeploy ([`install.md`](../operations/install.md#changing-the-site-values)).
+At start the Server logs `PushChannelConfigured` for each provider it can use. The end-to-end
+check, with an Alert on both phones, is [`docs/bench/push-checklist.md`](../bench/push-checklist.md).
+
+## Troubleshooting
+
+- **The Unreachable notice** ("Can't reach your Coldframe Server") right after install: the build
+ has no URLs, so it uses the `.invalid` defaults. Rebuild with the properties or the local
+ xcconfig above. If the URLs are right, the phone is not on the home Wi-Fi or bypasses the LAN
+ resolver ([`split-dns.md`](../operations/split-dns.md#phones-must-not-bypass-the-lan-resolver)).
+- **A redirect error from Keycloak at sign-in** ("Invalid parameter: redirect_uri"): the app's
+ redirect scheme and the realm's `coldframe-mobile` client do not match ([Choose your ids](#choose-your-ids)),
+ or `coldframe.keycloakClientId` / `COLDFRAME_KEYCLOAK_CLIENT_ID` names another client. In the web
+ app the same error means the realm was written for another origin
+ ([deploy guide, section 4](../deploy/README.md#4-keycloak-realm-and-phase-two)); the realm is
+ imported once, so fix an installed realm in the Keycloak admin console.
+- **No push**: the Server log names the provider without credentials (`PushChannelNotConfigured`)
+ or with unusable ones (`PushChannelCredentialsUnusable`); the Firebase app's package name or the
+ APNs topic differs from the id you built; or the iOS build has `COLDFRAME_PUSH = NO`.
diff --git a/docs/check-commands.sh b/docs/check-commands.sh
index 0917542..ae7570f 100755
--- a/docs/check-commands.sh
+++ b/docs/check-commands.sh
@@ -10,7 +10,9 @@
# `source ~/export-esp.sh` when it exists (the guide's
# install block creates it), with errexit and pipefail;
# the block needs a board: only `espflash --help`
-# runs, for every espflash subcommand in it.
+# runs, for every espflash subcommand in it;
+# the block needs a phone, Xcode, the Android SDK or a cluster
+# (Story 10.3): nothing runs; --list shows it as manual.
#
# An unmarked block, a marker that no bash/sh block follows, an unknown ci: marker or a ci:run
# directory that does not exist fails. --list prints every block with its marker and runs nothing.
@@ -29,7 +31,7 @@ for arg in "$@"; do
case ${arg} in
--list) list=true ;;
-h | --help)
- sed -n '2,19p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ sed -n '2,21p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
;;
-*)
@@ -56,7 +58,7 @@ work=$(mktemp -d)
trap 'rm -rf "${work}"' EXIT
# Splits a Markdown file into ${work}/.sh blocks and prints one manifest line per block or
-# problem: " ", separated by
+# problem: " ", separated by
# the unit separator 0x1F (a tab would collapse an empty argument).
# Plain POSIX awk: the CI runner's awk is mawk.
extract() {
@@ -74,6 +76,7 @@ extract() {
sub(/^[ \t]*[ \t]*$/, "", m)
if (m == "ci:hardware") { pending = "hardware"; arg = "" }
+ else if (m == "ci:manual") { pending = "manual"; arg = "" }
else if (m ~ /^ci:run[ \t]+[^ \t]+$/) { pending = "run"; arg = trim(substr(m, 7)) }
else { emit("unknown", m, ""); pending = "" }
next
@@ -165,7 +168,7 @@ for file in "${files[@]}"; do
case ${kind} in
unmarked)
blocks=$((blocks + 1))
- fail "${where}: unmarked bash/sh block; precede it with or "
+ fail "${where}: unmarked bash/sh block; precede it with , or "
${list} && indent "${block}"
;;
orphan) fail "${where}: a ci:${arg} marker is not followed by a bash/sh block" ;;
@@ -190,6 +193,15 @@ for file in "${files[@]}"; do
check_hardware "${where}" "${block}"
fi
;;
+ manual)
+ blocks=$((blocks + 1))
+ if ${list}; then
+ echo "${where}: ci:manual"
+ indent "${block}"
+ else
+ echo "==> ${where}: ci:manual (not run: needs a phone, Xcode, the Android SDK or a cluster)"
+ fi
+ ;;
esac
done < <(extract "${file}" "${index}")
done
diff --git a/docs/check-links.sh b/docs/check-links.sh
index e3738d2..4984200 100755
--- a/docs/check-links.sh
+++ b/docs/check-links.sh
@@ -6,8 +6,9 @@
#
# Each root is a directory (every *.md below it) or a Markdown file, relative to the repository
# root unless absolute. The default is `docs hardware README.md apps/rs/hub/README.md
-# apps/rs/node/README.md` (the build guide sends readers to the firmware READMEs). Outside fenced
-# code blocks, two things are checked:
+# apps/rs/node/README.md deploy/compose/README.md` (the build guide sends readers to the firmware
+# READMEs, the deploy guide to the Compose example). Outside fenced code blocks, two things are
+# checked:
#
# - inline links `[text](target)`: the file part of the target (no #anchor, no ?query), relative
# to the Markdown file, or to the repository root when it starts with `/`;
@@ -24,13 +25,13 @@ here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
repo=${DOCS_REPO_ROOT:-$(cd "${here}/.." && pwd)}
if [[ ${1:-} == -h || ${1:-} == --help ]]; then
- sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ sed -n '2,21p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
fi
roots=("$@")
if [[ ${#roots[@]} -eq 0 ]]; then
- roots=(docs hardware README.md apps/rs/hub/README.md apps/rs/node/README.md)
+ roots=(docs hardware README.md apps/rs/hub/README.md apps/rs/node/README.md deploy/compose/README.md)
fi
files=()
diff --git a/docs/deploy/README.md b/docs/deploy/README.md
new file mode 100644
index 0000000..d190b88
--- /dev/null
+++ b/docs/deploy/README.md
@@ -0,0 +1,235 @@
+# Deploy Coldframe at home
+
+This guide takes you from a domain and a spare machine to Coldframe running on your home network,
+signed in from your phone. The supported deployment is one RKE2 server on which Fleet installs the
+whole stack from this repository at a release tag, with public Let's Encrypt certificates and
+split DNS. The [Compose example](../../deploy/compose/README.md) is a reference only and is not
+supported.
+
+The guide fixes the order and the decisions, and adds the steps no runbook has. The commands that
+already exist stay in the operator runbooks, which remain the source of truth; each section links
+the part to follow:
+
+- [`docs/operations/install.md`](../operations/install.md): Fleet, the Secrets, the site values,
+ the GitRepo, upgrades and the verification checklist;
+- [`docs/operations/split-dns.md`](../operations/split-dns.md): the DNS-01 token, the split DNS
+ records and the resolvers;
+- [`deploy/SECRETS.md`](../../deploy/SECRETS.md): every Secret and how to create it;
+- [`docs/operations/restore.md`](../operations/restore.md): restoring the database from the
+ off-node backups.
+
+Work through the six sections in order:
+
+1. [Domain and DNS API token](#1-domain-and-dns-api-token)
+2. [RKE2 and Fleet](#2-rke2-and-fleet)
+3. [Secrets](#3-secrets)
+4. [Keycloak realm and Phase Two](#4-keycloak-realm-and-phase-two)
+5. [Split DNS](#5-split-dns)
+6. [First sign-in](#6-first-sign-in)
+
+## Before you start
+
+- **A home server** that runs all the time: one amd64 or arm64 machine (the images are published
+ for both) with a fixed LAN address reserved in the router's DHCP, for example `192.168.1.10`, a
+ 64-bit Linux that RKE2 supports, at least 4 GB of RAM (8 GB leaves the stack room) and an SSD for
+ the database. RKE2's own requirements:
+ [docs.rke2.io/install/requirements](https://docs.rke2.io/install/requirements).
+- **A release tag** `vX.Y.Z` of this repository. Everything is tied to it: the charts Fleet
+ installs, the images, the firmware you flash and the apps you build
+ ([`docs/operations/release.md`](../operations/release.md)). Check out that tag on the machine you
+ run the commands from: `git checkout vX.Y.Z`.
+- **A local DNS resolver** you control (the router's host overrides, Pi-hole, AdGuard Home,
+ Unbound, dnsmasq), handed out by DHCP to every device ([`split-dns.md`](../operations/split-dns.md#what-you-need)).
+- **An SMTP account** that can send mail from an address of yours: registration needs a verified
+ email, and invitations are emailed.
+- **On the machine you run the commands from**: `git`, `kubectl`, `helm`, `curl`, `sha256sum`,
+ `jq`, `openssl` and, for the backup check, the `aws` CLI.
+
+What costs money: the domain (yearly), the S3 bucket for the backups (by stored size), the SMTP
+service if yours is not free, and, for iPhones, the Apple Developer Program (yearly). Cloudflare's
+free plan and Firebase Cloud Messaging cost nothing.
+
+## 1. Domain and DNS API token
+
+Coldframe serves HTTPS only, with Let's Encrypt certificates obtained through the DNS-01 challenge:
+Let's Encrypt never connects to your network, and nothing listens on port 80.
+
+1. Choose a **dedicated subdomain** of a domain you own as Coldframe's domain, for example
+ `coldframe.example.org` (written `` below). Prefer it over the apex: the LAN record for
+ the web host would hide any public site at the apex.
+2. Host the domain's **DNS zone at Cloudflare** (the free plan is enough). Cloudflare is the only
+ DNS provider the `ingress` chart supports.
+3. Coldframe uses three hosts: `` (the web app), `api.` (the Server) and
+ `auth.` (Keycloak). Do not create public records for them; section 5 adds them to your
+ LAN resolver only.
+4. Create a Cloudflare **API token** with the permissions **Zone → DNS → Edit** and
+ **Zone → Zone → Read**, limited to your zone:
+ [`split-dns.md`, step 1](../operations/split-dns.md#1-the-dns-01-token). Keep it in a password
+ manager for now; it becomes the Secret `coldframe-dns01` in section 3.
+
+## 2. RKE2 and Fleet
+
+**The node must use your LAN resolver.** The Server and the web app validate tokens against
+`https://auth.` from inside the cluster, so the node, and CoreDNS through it, must resolve
+the three hosts to the LAN address. Configure the node's DNS to be the LAN resolver of section 5
+now (in its network configuration, without a public fallback server); section 5 adds the records
+and checks it ([`split-dns.md`](../operations/split-dns.md#the-node-and-the-cluster-must-use-this-resolver)).
+
+Install RKE2 v1.36.4+rke2r1 as a single server on the home server:
+
+```sh
+curl -sfL https://get.rke2.io | sudo INSTALL_RKE2_VERSION=v1.36.4+rke2r1 sh -
+sudo systemctl enable --now rke2-server.service
+systemctl status rke2-server --no-pager # active (running); the first start takes a few minutes
+```
+
+RKE2 writes the cluster-admin kubeconfig to `/etc/rancher/rke2/rke2.yaml` and puts `kubectl` in
+`/var/lib/rancher/rke2/bin`. Copy the kubeconfig to the machine you run the commands from
+(replace `127.0.0.1` in it with the node's LAN address when that is another machine), and check
+the node:
+
+```sh
+export PATH="$PATH:/var/lib/rancher/rke2/bin" # on the node: RKE2's kubectl
+mkdir -p ~/.kube
+sudo cat /etc/rancher/rke2/rke2.yaml > ~/.kube/config && chmod 600 ~/.kube/config
+kubectl get nodes # one node, Ready
+```
+
+Phones and browsers reach Coldframe on TCP port 443 of the node: allow it inbound in the node's
+firewall (with firewalld on Fedora or RHEL, `sudo firewall-cmd --permanent --add-port=443/tcp`
+and `sudo firewall-cmd --reload`), and follow RKE2's firewall guidance in
+[docs.rke2.io/install/requirements](https://docs.rke2.io/install/requirements).
+
+RKE2 brings its own Traefik, which is Coldframe's ingress. Do not apply
+`deploy/rke2/rke2-traefik-config.yaml` by hand: the `rke2-traefik` bundle does
+([`deploy/rke2/README.md`](../../deploy/rke2/README.md)).
+
+Then install Fleet from the pinned, checksummed charts:
+[`install.md`, step 1](../operations/install.md#1-install-fleet). The rest of the prerequisites
+are in [`install.md`](../operations/install.md#prerequisites); the S3 bucket for the backups is
+needed in section 3.
+
+## 3. Secrets
+
+Every credential is a Kubernetes Secret you create once, in the namespace `coldframe`, before
+Fleet installs anything. The charts never create one, and nothing secret goes into Git. Create
+the namespace first, since the Secret commands only name it:
+
+```sh
+kubectl create namespace coldframe
+```
+
+Then follow [`deploy/SECRETS.md`, Creating the Secrets](../../deploy/SECRETS.md#creating-the-secrets), which
+creates:
+
+- `coldframe-db-coldframe`, `coldframe-db-temporal`, `coldframe-db-keycloak`: the database roles
+ (labelled `cnpg.io/reload=true`);
+- `coldframe-keycloak-admin`: Keycloak's temporary bootstrap admin (keep it after section 6);
+- `coldframe-oidc-clients`: the secrets of the `coldframe-web` and `coldframe-server` clients;
+- `coldframe-smtp`: your SMTP account, used by Keycloak for verification and invitation emails;
+- `coldframe-dns01`: the Cloudflare token of section 1;
+- `coldframe-enrolment-key` and `coldframe-device-kek`: Device enrolment. **Record the KEK before
+ you create the Secret**, apart from the backups: without it no enrolled Device works, and a
+ restore needs the same value;
+- `coldframe-backup-s3`: the key of the S3 bucket off the node;
+- `coldframe-push`, **optional**: your APNs key and Firebase service account, only for push
+ notifications to the apps you build ([`docs/build/apps.md`](../build/apps.md#server-push)).
+
+The order and what a missing Secret does: [`install.md`, step 2](../operations/install.md#2-secrets-and-the-realm).
+
+## 4. Keycloak realm and Phase Two
+
+Keycloak needs the `coldframe` realm with the redirect URIs of your web host. A wrong redirect URI
+only shows up as a Keycloak error at the first sign-in, so write the realm with the script rather
+than by hand. From the repository root, at the release tag:
+
+```sh
+deploy/realm/prepare-realm.sh --web-origin https:// --out coldframe-realm.json
+kubectl -n coldframe create configmap coldframe-realm --from-file=coldframe-realm.json=coldframe-realm.json
+```
+
+[`prepare-realm.sh`](../../deploy/realm/prepare-realm.sh) (needs `jq`) takes the realm of the local
+stack, [`aspire/keycloak/realms/coldframe-realm.json`](../../aspire/keycloak/realms/coldframe-realm.json),
+and sets the web client's sign-in and sign-out callbacks to `https:///.oidc/signin/callback`
+and `https:///.oidc/signout/callback`. It leaves everything else as it is: the `${COLDFRAME_*}`
+placeholders, which Keycloak fills from `coldframe-oidc-clients` and `coldframe-smtp`, and the mobile
+client with its `com.escendit.coldframe` redirect scheme ([apps guide](../build/apps.md#choose-your-ids)).
+It refuses an `http://` origin.
+
+To replace the ConfigMap, for example after a wrong `--web-origin`, before Keycloak's first start
+(`create` fails with AlreadyExists when it exists):
+
+```sh
+kubectl -n coldframe create configmap coldframe-realm --from-file=coldframe-realm.json=coldframe-realm.json \
+ --dry-run=client -o yaml | kubectl apply -f -
+```
+
+After the first start the ConfigMap no longer matters: the realm is never re-imported.
+
+Keycloak runs on Phase Two, and the realm file turns on what Coldframe uses of it:
+
+- **Organizations**: each Site is a Phase Two organization; the Server manages them through its
+ service account.
+- **The `coldframe` email theme**, shipped in the Keycloak image, with the home-network hint in
+ every invitation: "Open the link at home, on the garden's Wi-Fi: accepting needs the home network."
+- **The Invitation required action**, which shows an invitee the invitation after they sign in.
+- **Verify email**: Phase Two offers an invitation only to a verified address.
+
+The SMTP server comes from `coldframe-smtp` when the realm is imported, and **the realm is imported
+once only**, on Keycloak's first start. Later changes, including a new SMTP server or client secret,
+are made in the Keycloak admin console ([`install.md`, step 2](../operations/install.md#2-secrets-and-the-realm)).
+
+Then write the site values, the one ConfigMap with your domain, S3 bucket and ACME email:
+[`install.md`, step 3](../operations/install.md#3-the-site-values). Its `keycloak.realmImport.configMap`
+is `coldframe-realm`, the ConfigMap above.
+
+## 5. Split DNS
+
+On your LAN resolver, add one A record per host, each pointing to the home server's LAN address:
+[`split-dns.md`, step 5](../operations/split-dns.md#5-split-dns-records). Use host records, not a
+local zone for the whole domain, so the `_acme-challenge` records stay public.
+
+Check that the node and the cluster resolve the hosts to the LAN address
+([`split-dns.md`](../operations/split-dns.md#the-node-and-the-cluster-must-use-this-resolver)), and
+that phones and browsers do not bypass the LAN resolver with Private DNS or DNS over HTTPS
+([`split-dns.md`](../operations/split-dns.md#phones-must-not-bypass-the-lan-resolver)).
+
+### Install the stack
+
+With the records in place, set `spec.revision` of the GitRepo to your release tag and apply it, then
+watch the bundles become Ready in order ([`install.md`, steps 4 and 5](../operations/install.md#4-apply-the-gitrepo)).
+The certificate is issued within a few minutes. Then verify DNS and TLS from a laptop on the home
+network: [`split-dns.md`, step 7](../operations/split-dns.md#7-verify).
+
+A private CA is only an unsupported fallback for when DNS-01 with a public domain is impossible
+([`split-dns.md`](../operations/split-dns.md#fallback-a-private-ca-unsupported)); the Hub and the
+apps trust public roots only.
+
+## 6. First sign-in
+
+1. On a phone or laptop **on the home Wi-Fi**, open `https://`. There must be no
+ certificate warning.
+2. Choose **Register**, and create your account.
+3. **Verify your email**: Keycloak sends the link through `coldframe-smtp`. Without working SMTP
+ no verification email arrives; check the Keycloak log
+ (`kubectl -n coldframe logs deployment/keycloak`) and the Secret.
+4. **Create a Site.** Whoever creates a Site is its Owner, and can invite others by email.
+
+Then replace Keycloak's temporary bootstrap admin: sign in to `https://auth./admin` with
+the `coldframe-keycloak-admin` credentials, create a permanent admin in the master realm and
+delete the temporary one. Keep the Secret: the Keycloak pod does not start without it
+([`deploy/SECRETS.md`](../../deploy/SECRETS.md#creating-the-secrets), after the commands).
+
+Finally tick the [verification checklist](../operations/install.md#verification-checklist-on-the-home-server):
+bundles and pods, HTTPS on the three hosts, port 80 closed, sign-in from a phone and a browser,
+restart durability and a backup in S3.
+
+## Afterwards
+
+- **Phones**: build the iOS and Android apps for your domain, with your own push credentials:
+ [`docs/build/apps.md`](../build/apps.md). Hubs and Nodes: [the build guide](../build/README.md).
+- **Upgrades** are a new release tag in the GitRepo; read the release notes first
+ ([`install.md`, Upgrades](../operations/install.md#upgrades)). Rebuild the apps from the same tag.
+- **Verification**: tick the checklist again after every upgrade.
+- **Restore**: when the database is lost or damaged, restore it from the S3 backups with the
+ same `coldframe-device-kek` ([`restore.md`](../operations/restore.md)).
diff --git a/docs/operations/install.md b/docs/operations/install.md
index aecb571..90f2978 100644
--- a/docs/operations/install.md
+++ b/docs/operations/install.md
@@ -84,14 +84,20 @@ the Device enrolment key `coldframe-enrolment-key` and the Device key-encryption
apart from the database backups and their credentials, since no enrolled Device works without it and
together with a backup it decrypts every Device key), and the ConfigMap `coldframe-realm` holding the `coldframe` realm file with the
redirect URIs of your hosts ([`deploy/charts/README.md`](../../deploy/charts/README.md#prerequisites),
-items 1 to 4):
+items 1 to 4). Write the realm file with [`deploy/realm/prepare-realm.sh`](../../deploy/realm/prepare-realm.sh),
+which sets the web client's redirect URIs to your web origin; the adopter guide
+[`docs/deploy/`](../deploy/README.md) puts these steps in order:
```sh
kubectl create namespace coldframe
# kubectl -n coldframe create secret generic ... (deploy/SECRETS.md)
-kubectl -n coldframe create configmap coldframe-realm --from-file=coldframe-realm.json=
+deploy/realm/prepare-realm.sh --web-origin https:// --out coldframe-realm.json
+kubectl -n coldframe create configmap coldframe-realm --from-file=coldframe-realm.json=coldframe-realm.json
```
+To replace an existing `coldframe-realm` (before Keycloak's first start; the realm is imported once),
+add `--dry-run=client -o yaml | kubectl apply -f -` to the `create configmap` command.
+
`coldframe-push` is the one Secret you may leave out: without it the Server runs and sends no push
notification. To notify phones, create it and set `push.apns.enabled: true` and/or
`push.fcm.enabled: true` (and `push.apns.topic`, your iOS bundle ID) under the `server` key of your
diff --git a/docs/quickstart.md b/docs/quickstart.md
index ad6c82c..cebef6b 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -466,7 +466,7 @@ apps/ runtimes: firmware, Server, web app, mobile shells
packages/ what the runtimes reference: contracts, shared libraries, generated clients
tests/ all tests, by language: cs/ rs/ kt/ swift/ ts/
aspire/ the local stack and integration-test host
-deploy/ Helm charts, Fleet bundles, Compose example
+deploy/ Helm charts, Fleet bundles, Compose reference example (deploy/compose)
hardware/ reference design: schematics, BOM, wiring, enclosure
docs/ guides and references
```
diff --git a/docs/test.sh b/docs/test.sh
index 99520d7..ad83c84 100755
--- a/docs/test.sh
+++ b/docs/test.sh
@@ -290,6 +290,40 @@ else
failed "--list runs nothing" "ran: $(ls "${listed}/apps/rs/node")" "calls: $(cat "${calls}")"
fi
+manual=$(fixture manual)
+page "${manual}" docs/build/flash.md '# Flash
+
+
+
+```bash
+touch ran
+espflash bogus
+```'
+: > "${calls}"
+expect "a ci:manual block is not run" 0 "docs/build/flash.md:5: ci:manual (not run" "" -- check "${manual}"
+if [[ ! -e ${manual}/ran && ! -e ${manual}/apps/rs/node/ran && ! -s ${calls} ]]; then
+ pass "a ci:manual block runs nothing, not even espflash --help"
+else
+ failed "a ci:manual block runs nothing, not even espflash --help" "calls: $(cat "${calls}")"
+fi
+expect "--list shows a ci:manual block as manual" 0 "docs/build/flash.md:5: ci:manual" "" -- check "${manual}" --list
+
+manual_arg=$(fixture manual-arg)
+page "${manual_arg}" docs/build/flash.md '
+```bash
+true
+```'
+expect "a ci:manual marker with an argument is an unknown marker" 1 - \
+ "docs/build/flash.md:1: unknown marker 'ci:manual apps/rs/node'" -- check "${manual_arg}"
+
+manual_orphan=$(fixture manual-orphan)
+page "${manual_orphan}" docs/build/flash.md '
+```text
+not a command block
+```'
+expect "a ci:manual marker before a non-shell block is an orphan" 1 - "a ci:manual marker is not followed" \
+ -- check "${manual_orphan}"
+
no_export=$(fixture no-export)
page "${no_export}" docs/build/flash.md '
```bash