diff --git a/AGENTS.md b/AGENTS.md
index fb21331..a8cc7e2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1418,6 +1418,15 @@ session with **no other memory of this project**. So:
it in when known.
**Profile discovery (built 2026-09-29):** the carrier is the existing kind-0
**`cordn`** field (app convention, documented in NIP.md — not a new kind).
+ **Crew-owned coordinator kit (2026-10-31):** `deploy/cordn/` is the
+ production hand-off for crews: Docker Compose, SQLite volume, `.env` template,
+ backup/restore scripts and a runbook. Cordn connects **outbound** to ContextVM
+ relays; no inbound port, domain or TLS terminator is required for the
+ coordinator itself. The app still does **not** embed a coordinator. Settings →
+ Coordinator links to the kit; the crew's `coordinator` tag/operator remains
+ the adoption path. Keep the coordinator key separate from crew/admin keys.
+ Do not claim multi-coordinator failover; this is one trusted delivery service
+ with backups, not BFT.
`EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
http URL), **normalises a bare pointer to Cordn's canonical add-link** before
saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`
diff --git a/deploy/cordn/.env.example b/deploy/cordn/.env.example
new file mode 100644
index 0000000..b9d817a
--- /dev/null
+++ b/deploy/cordn/.env.example
@@ -0,0 +1,19 @@
+# Copy this file to .env, then generate a key:
+# openssl rand -hex 32
+# Keep .env mode 600 and back it up with the coordinator data.
+CORDN_SERVER_PRIVATE_KEY=
+
+# ContextVM relays the coordinator connects to. Add a second one if you want
+# more reachability; the coordinator does not need an inbound port.
+CORDN_RELAY_URLS=wss://relay.contextvm.org,wss://relay2.contextvm.org
+
+# Publicly announce the coordinator on its relays.
+CORDN_ANNOUNCED=true
+CORDN_SERVER_NAME=my-crew-coordinator
+
+# Per-identity abuse protection. Raise for larger crews; keep it bounded.
+CORDN_RATE_LIMIT_ENABLED=true
+CORDN_RATE_LIMIT_REFILL_PER_MINUTE=250
+CORDN_RATE_LIMIT_BURST=80
+CORDN_MAX_KEY_PACKAGES_PER_IDENTITY=50
+CORDN_MAX_LAST_RESORT_KEY_PACKAGES_PER_IDENTITY=1
diff --git a/deploy/cordn/README.md b/deploy/cordn/README.md
new file mode 100644
index 0000000..11dab53
--- /dev/null
+++ b/deploy/cordn/README.md
@@ -0,0 +1,127 @@
+# Crew-owned Cordn coordinator
+
+This kit runs a **Cordn MLS delivery-service coordinator** that a crew owns. It
+is transport only: the coordinator sees ciphertext and routing metadata (who,
+when, which group), never message plaintext. It can withhold or delay messages;
+it cannot forge members' messages.
+
+This is a **server** kit. SkateSpots remains a Cordn *client*; it does not embed a
+coordinator in the APK.
+
+## What you need
+
+- A small Linux server or always-on home machine with Docker + Compose.
+- Outbound HTTPS/WSS access. **No inbound port is required** — the coordinator
+ connects out to its ContextVM relay(s).
+- A domain is **not** required for the coordinator itself.
+- A crew willing to trust the operator with availability and metadata.
+
+## 1. Install
+
+```bash
+cp .env.example .env
+chmod 600 .env
+openssl rand -hex 32
+```
+
+Put the generated value into `CORDN_SERVER_PRIVATE_KEY`. Set
+`CORDN_RELAY_URLS` to the relay(s) you want; the default is ContextVM’s public
+relay. Set a useful `CORDN_SERVER_NAME`.
+
+```bash
+docker compose up -d
+docker compose logs -f cordn
+```
+
+The server prints its coordinator public identity/add link. It may show an
+`nprofile` or a `cordn.net/chat/coordinators?c=…` link.
+
+If your build does not print an add link, derive the public key from the private
+key using the Cordn CLI or `nostr-tools`, then encode an `nprofile` with the same
+relay hints. Keep the private key on the server.
+
+## 2. Adopt it in SkateSpots
+
+Either:
+
+- Settings → **Coordinator** → paste the `nprofile`/add-link → **Add**; or
+- Crew edit → **Coordinator** → paste it and save; or
+- scan the coordinator QR.
+
+Use exactly the relay hints from the server. A mismatch produces an honest
+“Not seen” health state even when the server is actually running.
+
+The coordinator should show **Online** (or “Stale” if its heartbeat is old).
+“Offline” means presence was not found, not necessarily that the service is
+broken.
+
+## 3. What the crew should know
+
+- The coordinator can **censor or drop messages**.
+- It cannot **read** MLS plaintext.
+- It can see **who, when and which group**.
+- It can disappear; local messages remain on members' devices, but delivery
+ pauses until the coordinator returns or the crew switches.
+- The operator should **not** also become a crew admin automatically.
+- A loss of the database is a loss of delivery history; back it up.
+
+## 4. Backup
+
+```bash
+./backup.sh
+```
+
+Backups land in `./backups/` as a compressed copy of the coordinator volume.
+Store them off the server, encrypted.
+
+## 5. Restore
+
+Stop the coordinator, restore the archive into the `cordn-data` volume, start it
+again:
+
+```bash
+docker compose stop cordn
+./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
+docker compose start cordn
+```
+
+## 6. Upgrade
+
+```bash
+docker compose pull
+docker compose up -d
+```
+
+The image is pinned only by the `latest` tag. If you need reproducible upgrades,
+pin a digest and record it in your crew runbook.
+
+## 7. Move to a new coordinator
+
+MLS delivery state is tied to the coordinator’s database and identity. Before
+switching:
+
+1. back up the current coordinator;
+2. export/retain the crew’s local chat state on members’ devices;
+3. start the new coordinator;
+4. paste its `nprofile` into the crew record;
+5. have members reopen the private chat and re-establish delivery;
+6. update the crew operator/notice.
+
+Do not claim the switch is seamless unless the old and new coordinator state
+have actually been migrated.
+
+## Security notes
+
+- Never commit `.env`; it contains the coordinator signing key.
+- The coordinator key is **not** a crew admin key and must not be reused as one.
+- Run it behind a trusted host/network; outbound relay traffic is metadata.
+- Keep Docker and the image current.
+- Do not expose an OpenCode or coordinator admin port publicly.
+- This is **not** a BFT or multi-coordinator system. One coordinator is a
+ delivery service under the crew's social trust.
+
+## Files
+
+- `docker-compose.yml` — persistent SQLite deployment.
+- `.env.example` — required environment.
+- `backup.sh` / `restore.sh` — volume snapshots.
diff --git a/deploy/cordn/backup.sh b/deploy/cordn/backup.sh
new file mode 100755
index 0000000..f98428b
--- /dev/null
+++ b/deploy/cordn/backup.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+# Snapshot the Cordn coordinator data volume. Run from this directory.
+set -eu
+cd "$(dirname "$0")"
+mkdir -p backups
+stamp="$(date +%Y%m%d-%H%M%S)"
+out="cordn-$stamp.tgz"
+docker run --rm \
+ -v cordn_cordn-data:/data:ro \
+ -v "$PWD/backups":/backup \
+ alpine:3.20 \
+ sh -c "tar czf /backup/$out -C /data ."
+echo "Backup: $PWD/backups/$out"
+echo "Store it off this server and keep it encrypted."
diff --git a/deploy/cordn/docker-compose.yml b/deploy/cordn/docker-compose.yml
new file mode 100644
index 0000000..063d0c5
--- /dev/null
+++ b/deploy/cordn/docker-compose.yml
@@ -0,0 +1,19 @@
+name: cordn
+
+services:
+ cordn:
+ image: ghcr.io/cordn-msg/cordn:latest
+ container_name: cordn
+ restart: unless-stopped
+ env_file:
+ - .env
+ environment:
+ CORDN_STORAGE_BACKEND: sqlite
+ CORDN_SQLITE_PATH: /data/cordn.sqlite
+ volumes:
+ - cordn-data:/data
+ # No inbound port is required: the coordinator connects *out* to the
+ # ContextVM relay(s) configured in CORDN_RELAY_URLS.
+
+volumes:
+ cordn-data:
diff --git a/deploy/cordn/restore.sh b/deploy/cordn/restore.sh
new file mode 100755
index 0000000..a5a9c9a
--- /dev/null
+++ b/deploy/cordn/restore.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Restore a Cordn coordinator backup into the cordn-data volume.
+# Usage: ./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
+set -eu
+cd "$(dirname "$0")"
+[ "$#" -eq 1 ] || { echo "usage: $0 backups/cordn-YYYYMMDD-HHMMSS.tgz" >&2; exit 1; }
+archive="$1"
+[ -f "$archive" ] || { echo "no such backup: $archive" >&2; exit 1; }
+printf 'This replaces the current coordinator volume. Type "restore" to continue: '
+read -r answer
+[ "$answer" = restore ] || { echo "aborted" >&2; exit 1; }
+docker run --rm \
+ -v cordn_cordn-data:/data \
+ -v "$(cd "$(dirname "$archive")" && pwd)":/backup:ro \
+ alpine:3.20 \
+ sh -c "rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true; tar xzf /backup/$(basename "$archive") -C /data"
+echo "Restored $(basename "$archive")."
diff --git a/src/components/CoordinatorSettingsCard.tsx b/src/components/CoordinatorSettingsCard.tsx
index c5ff61b..4600d83 100644
--- a/src/components/CoordinatorSettingsCard.tsx
+++ b/src/components/CoordinatorSettingsCard.tsx
@@ -21,6 +21,7 @@ import { toast } from '@/hooks/useToast';
import {
CORDN_APP_URL,
CORDN_DOCKER_COMMAND,
+ CORDN_DEPLOY_KIT_URL,
CORDN_RELAY_URL,
coordinatorAddUrl,
parseCoordinatorInput,
@@ -314,6 +315,14 @@ export function CoordinatorSettingsCard() {