eleven / ocho Public

deploy: retire the Miren container path; keep Docker as a first-class self-host option (#954)

The rollback window for lchat.run.garden (the last Miren deployment) is
over, so the platform-specific machinery goes: Dockerfile.agent,
home/push Dockerfiles, the .miren/ and home//push/ app.toml manifests,
and deploy/miren*. The main Dockerfile stays as the supported container
self-host path and gets fixed properly: distroless :nonroot (uid 65532)
with /data pre-owned so named volumes inherit writable ownership, and
the build image bumped 1.23→1.25 to match go.mod (the build had been
silently broken since the toolchain bump — nothing was exercising it).

Verified: image builds; container serves /api/version as uid 65532;
db/WAL/blobs created on a named volume owned by 65532.

Docs follow reality: README's Miren section becomes a generic Docker
recipe, CLAUDE.md's Deploy section describes the router/systemd fleet,
and bots.md / home/README.md / push-relay.md deploy notes point at the
lchat-bot@/lchat-home/lchat-push units. Historical mentions of Miren
stay where they explain how we got here.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e74324c563e93ea42f971183a7b616ddba72495d parent 75a6196
17 files changed, +96 −553
  • .miren/agent.app.toml +0 −40
  • .miren/app.toml +0 −44
  • CLAUDE.md +16 −9
  • Dockerfile +13 −5
  • Dockerfile.agent +0 −22
  • README.md +31 −49
  • deploy/miren.sh +0 −59
  • deploy/miren/agent.sh +0 −52
  • deploy/miren/home.sh +0 −51
  • deploy/miren/push.sh +0 −63
  • docs/bots.md +26 −41
  • docs/push-relay.md +6 −5
  • home/Dockerfile +0 −21
  • home/README.md +4 −3
  • home/app.toml +0 −34
  • push/Dockerfile +0 −21
  • push/app.toml +0 −34
diff --git a/.miren/agent.app.toml b/.miren/agent.app.toml
deleted file mode 100644
index cfacc1a..0000000
--- a/.miren/agent.app.toml
+++ /dev/null
@@ -1,40 +0,0 @@
-# Miren config for a bot sidecar app (one per lchat instance). Templated by
-# deploy/miren/agent.sh (__APP__ -> "<instance>-bot"). This is a worker, not a public
-# site: it has a service+port only so Miren can health-check it (see the health
-# endpoint in agent.mjs); no route is set, so it has no public hostname.
-name = "__APP__"
-
-[build]
-dockerfile = "Dockerfile.agent"
-
-[services.web]
-port = 8080
-
-# One instance, and a persistent disk (fixed concurrency is required for a disk).
-[services.web.concurrency]
-mode = "fixed"
-num_instances = 1
-
-# Persists the agent's session (device token + private key) across redeploys, so
-# it doesn't re-provision. Private keys never leave this disk.
-[[services.web.disks]]
-name = "__APP__-data"
-mount_path = "/data"
-size_gb = 1
-
-[[env]]
-key = "LCHAT_SESSION"
-value = "/data/session.json"
-
-
-# Eleven (the app guide): identity + welcomed-members state, same pattern.
-[[env]]
-key = "LCHAT_ELEVEN_SESSION"
-value = "/data/eleven-session.json"
-
-# Set at deploy time via `-e` (see deploy/miren/agent.sh), NOT declared here:
-# LCHAT_BASE the instance to connect to, e.g. https://test-bots.example.com
-# ANTHROPIC_API_KEY billing credential — lives only in this app's env
-# LCHAT_INVITE one-time invite #fragment; used only on first boot
-# LCHAT_ELEVEN_INVITE same, for the "Eleven" identity
-# PORT container port for the health check (defaults to 8080)
diff --git a/.miren/app.toml b/.miren/app.toml
deleted file mode 100644
index 58b2363..0000000
--- a/.miren/app.toml
+++ /dev/null
@@ -1,44 +0,0 @@
-# Miren deployment config for lchat.
-#
-# Most of this matches the documented Miren schema. Two bits are worth
-# verifying against your Miren version: the `name` field and the `[build]`
-# dockerfile reference. Run `miren init` once to generate a schema-correct
-# skeleton, then merge the `disks` + `env` blocks below into it, and
-# `miren deploy --analyze` to confirm before the real deploy.
-
-name = "lchat"
-
-[build]
-# Build from the in-repo Dockerfile (deterministic, fully static binary).
-# Remove this block to let Miren auto-detect the Go project instead.
-dockerfile = "Dockerfile"
-
-[services.web]
-# Miren terminates TLS and routes external HTTPS to this container port.
-# The app listens on :1747 by default (or $ADDR), so these must match.
-port = 1747
-
-# A persistent disk requires the service to run in fixed concurrency mode.
-# Single instance also suits SQLite's single-writer model.
-[services.web.concurrency]
-mode = "fixed"
-num_instances = 1
-
-# Persistent disk for the SQLite database, backed by the server's local storage.
-[[services.web.disks]]
-name = "lchat-data"
-mount_path = "/data"
-size_gb = 1
-
-[[env]]
-key = "LCHAT_DB"
-value = "/data/lchat.db"
-
-# LCHAT_ADMIN_TOKEN is set at deploy time via `miren deploy -e LCHAT_ADMIN_TOKEN=...`,
-# NOT declared here. Two hard-won Miren gotchas baked into that name/mechanism:
-# 1. The var is LCHAT_-prefixed, not a bare ADMIN_TOKEN — Miren auto-provisions
-# its own secret for ADMIN_TOKEN and injects it, overriding your value.
-# 2. Use `-e` (plain), not `-s` (sensitive) — Miren stores sensitive vars but
-# does not inject them into the container.
-# It is NOT a decryption key (messages stay end-to-end encrypted); it only gates
-# the invite backend. Admin URL: https://group.example.com/admin#token=<token>
diff --git a/CLAUDE.md b/CLAUDE.md
index a78424d..87b67b3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -498,10 +498,13 @@ event schema stays in sync across platforms.
## Deploy
-Deployed as a single binary on a container platform. Each chat is a
-**fully-isolated instance** — its own app, disk, admin token, and subdomain.
-`deploy/miren.sh` is the committed deploy script: it templates a per-instance
-config with a unique app + disk name and sets the route.
+Deployed as a single static binary per chat. Each chat is a **fully-isolated
+instance** — its own process, DB, admin token, and subdomain — supervised by
+systemd behind the instance router (`docs/instance-router.md`); shipping is
+via the signed release channels (`deploy/release-server.sh`,
+`docs/server-releases.md`). The `Dockerfile` exists for self-hosters on
+container platforms (README § Docker); nothing in our production runs in
+Docker (the original Miren container path was retired 2026-07-15).
> Environment specifics — server, platform/cluster name, the instance↔subdomain
> map, DNS, and secret locations — live in **`CLAUDE.local.md`** (gitignored),
@@ -509,12 +512,16 @@ config with a unique app + disk name and sets the route.
Principles that hold regardless of environment:
-- **Deploys are data-safe.** A plain redeploy (what `deploy/miren.sh` runs)
- **preserves the disk**. **Never delete the app to update it** — that wipes the
- disk; delete only to intentionally reset an instance. DB migrations are additive
+- **Deploys are data-safe.** `lchat deploy` repoints the instance's `live`
+ symlink and restarts — it never touches `/data/<host>.db*`. **Never delete
+ an instance's data to update it**; `lchat remove` deliberately leaves
+ `/data` behind, and deleting those files is only for intentionally
+ resetting an instance (or tearing down a canary). DB migrations are additive
(`CREATE TABLE IF NOT EXISTS`), so new code over old data is safe.
-- **Reuse an existing instance's admin token on redeploy** — `deploy/miren.sh`
- mints a new one if the token arg is omitted, which would invalidate saved links.
+- **Admin tokens survive redeploys** — they live in `/data/<host>.env`, which
+ deploys never touch. Only `add-instance` without `--admin-token` mints a
+ fresh one, which would invalidate saved links; reuse the existing token when
+ recreating an instance.
- **Env contract:** the app reads `LCHAT_ADMIN_TOKEN`, `LCHAT_DB`, `ADDR`. Use the
`LCHAT_`-prefix deliberately (a bare `ADMIN_TOKEN` can be auto-provisioned/
overridden by the platform).
diff --git a/Dockerfile b/Dockerfile
index 0378d60..8f34ba5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,17 +1,25 @@
# ---- build ----
-FROM golang:1.23-alpine AS build
+# Keep this in step with go.mod's `go` directive when it bumps.
+FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Pure-Go SQLite (modernc) means we can build a fully static binary with no cgo.
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /lchat .
+# Staged here so the runtime image (which has no shell to mkdir with) can copy
+# in a /data already owned by nonroot — a named volume then inherits writable
+# ownership on first use. A bind mount still needs a host-side chown to 65532.
+RUN mkdir -p /data
# ---- run ----
-# distroless/static: ~2MB, no shell, runs as root so it can write to the
-# mounted data volume. The frontend and SQLite engine are embedded in /lchat.
-FROM gcr.io/distroless/static-debian12
+# distroless/static:nonroot: ~2MB, no shell, runs as uid 65532 — the container
+# never needs root. The frontend and SQLite engine are embedded in /lchat.
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build --chown=nonroot:nonroot /data /data
COPY --from=build /lchat /lchat
-# Runtime config comes from env (see .miren/app.toml): LCHAT_DB, ADDR, ADMIN_TOKEN.
+# Runtime config comes from env: LCHAT_DB (point it at /data), ADDR,
+# LCHAT_ADMIN_TOKEN. See README.md § Docker.
EXPOSE 1747
+VOLUME /data
ENTRYPOINT ["/lchat"]
diff --git a/Dockerfile.agent b/Dockerfile.agent
deleted file mode 100644
index b134a0b..0000000
--- a/Dockerfile.agent
+++ /dev/null
@@ -1,22 +0,0 @@
-# The bot sidecar (a separate Miren app from the lchat server). Node runtime;
-# imports web/static/crypto.js verbatim, so both live under /app with their
-# relative layout preserved (agent.mjs -> ../web/static/crypto.js).
-FROM node:22-alpine
-WORKDIR /app
-
-# Install deps first for layer caching.
-COPY agent/package.json agent/package-lock.json ./agent/
-RUN cd agent && npm ci --omit=dev
-
-# App source + the shared browser modules the sidecar imports (crypto.js for E2E,
-# bot-commands.js for the slash-command list) — kept at ../web/static/ so the
-# relative imports resolve identically to the browser.
-COPY agent/ ./agent/
-COPY web/static/crypto.js web/static/bot-commands.js ./web/static/
-
-WORKDIR /app/agent
-EXPOSE 8080
-# Absolute path: the platform may launch the container with cwd=/app, not this
-# WORKDIR. Module resolution (node_modules, ../web/static/crypto.js) is relative
-# to the file, not cwd, so it works regardless.
-CMD ["node", "/app/agent/agent.mjs"]
diff --git a/README.md b/README.md
index f089220..514322b 100644
--- a/README.md
+++ b/README.md
@@ -97,8 +97,8 @@ GOOS=linux GOARCH=amd64 go build -o lchat . # cross-compile for a Linux box
```
See **[Deploy](#deploy)** for how to run it in production — a self-hosted router
-(the default), a single instance behind your own reverse proxy, Miren, or another
-platform.
+(the default), a single instance behind your own reverse proxy, Docker, or
+another platform.
## Native iOS & macOS app
@@ -214,66 +214,48 @@ LCHAT_DB=/srv/lchat/lchat.db ADDR=:1747 ./lchat # via a systemd unit in prac
Then proxy `https://chat.example.com` → `127.0.0.1:1747` (Caddy gets you TLS in
two lines).
-### Miren
+### Docker
-[Miren](https://miren.dev) is a managed container platform: it builds the
-container, runs it, and handles routing + TLS, so you don't run a reverse proxy
-yourself. This repo ships a `Dockerfile` and `.miren/app.toml` ready to go.
+The repo ships a `Dockerfile`: a fully static binary in a distroless image
+(~2MB of image around the binary, no shell), running as a **non-root** user.
+Runtime config is env vars:
-Runtime config is via env vars, so it doesn't matter how Miren launches the
-binary:
-
-- `LCHAT_DB` — SQLite path; point it at the persistent disk (`/data/lchat.db`)
-- `LCHAT_ADMIN_TOKEN` — admin token, set with `miren ... -e LCHAT_ADMIN_TOKEN=…`
-- `ADDR` — listen address (defaults to `:1747`, matching the declared port)
+- `LCHAT_DB` — SQLite path; point it at the persistent volume (`/data/lchat.db`)
+- `LCHAT_ADMIN_TOKEN` — admin token
+- `ADDR` — listen address (defaults to `:1747`, matching the exposed port)
> **Gotcha (learned the hard way):** the env var is `LCHAT_ADMIN_TOKEN`, *not*
-> `ADMIN_TOKEN`. Miren auto-provisions its own secret for a bare `ADMIN_TOKEN`
-> (it's "referenced in code") and injects that, silently overriding whatever you
-> set. The `LCHAT_` prefix dodges that. Also set it with `-e` (a plain env var) —
-> Miren stores `-s`/sensitive vars but does **not** inject them into the container.
-
-The SQLite file lives on a persistent **disk** (`.miren/app.toml` declares one
-at `/data`). Keep this service at a **single replica** — SQLite has one writer,
-which matches Miren's exclusive-lease disks.
+> `ADMIN_TOKEN`. Some container platforms auto-provision their own secret for a
+> bare `ADMIN_TOKEN` ("referenced in code") and inject that, silently overriding
+> whatever you set. The `LCHAT_` prefix dodges that.
```sh
-# 1. Point the CLI at your Miren cluster, then from the repo root:
-miren init # generates a schema-correct .miren/app.toml — merge
- # in the [[disks]] and [[env]] blocks from this repo
-miren deploy --analyze # confirm Miren detects the build + port
-
-# 2. Generate a URL-safe admin token (it goes in a URL #fragment):
+# 1. Build, and generate a URL-safe admin token (it goes in a URL #fragment):
+docker build -t lchat .
TOKEN=$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
-# 3. Deploy (pass the token as a plain -e env var) and attach the domain:
-miren deploy -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN"
-miren route set group.example.com lchat
+# 2. Run with a named volume for the database:
+docker run -d --name lchat --restart unless-stopped \
+ -p 127.0.0.1:1747:1747 \
+ -v lchat-data:/data \
+ -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN" \
+ lchat
# Your admin URL is then: https://group.example.com/admin#token=$TOKEN
```
-Then point DNS for `group.example.com` at the cluster (a CNAME to the
-`*.miren.systems` hostname Miren prints, or an A record to the server IP). Once
-DNS resolves, Miren provisions TLS automatically.
-
-**Updating later (no data loss):** just run `miren deploy` again — a normal deploy
-**preserves the disk**, so SQLite (messages, invites, push subscriptions, VAPID
-keys, group name) all survive. **Do not `miren app delete` to update** — that
-deletes the disk and wipes everything; use it only to intentionally reset.
+Then terminate TLS in front of it exactly as in the reverse-proxy section
+above (`https://group.example.com` → `127.0.0.1:1747`; make sure the proxy
+passes the `/ws` WebSocket upgrade).
-**More instances from one repo:** `./deploy/miren.sh <name> <name>.example.com`
-gives each its own app + disk + token + route (re-run it to update an instance;
-it preserves that instance's data too).
-
-Your admin URL is then:
-
-```
-https://group.example.com/admin#token=<ADMIN_TOKEN>
-```
+The container runs as uid 65532, and `/data` in the image is owned by that
+user — a **named volume** (as above) inherits the ownership on first use. If
+you'd rather **bind-mount** a host directory, chown it first
+(`chown 65532:65532 /srv/lchat-data`). Keep the service at a **single
+replica** — SQLite has one writer.
-> WebSockets: the live feed uses a standard HTTP upgrade on `/ws`. Miren's
-> router proxies this fine; if the feed ever shows "Reconnecting…" forever,
-> that's the first thing to check.
+**Updating later (no data loss):** rebuild and recreate the container; the
+volume (messages, invites, push subscriptions, VAPID keys, group name)
+survives. Only deleting the *volume* wipes data.
### Other platforms (Fly.io, Render, Railway, Heroku, …)
diff --git a/deploy/miren.sh b/deploy/miren.sh
deleted file mode 100755
index cfb1060..0000000
--- a/deploy/miren.sh
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/usr/bin/env bash
-# Spin up an additional, fully-isolated lchat instance from the single committed
-# app.toml — its own Miren app, disk, admin token, and route. Keeps git clean:
-# no per-instance config files.
-#
-# Usage: ./deploy/miren.sh <instance> <hostname> [admin-token]
-# ./deploy/miren.sh paja paja.example.com
-#
-# Miren's app.toml has no variable interpolation, so we template a throwaway copy
-# in a temp dir (unique app name + unique disk name) and deploy from there.
-set -euo pipefail
-
-INSTANCE="${1:?usage: deploy/miren.sh <instance> <hostname> [admin-token]}"
-HOST="${2:?usage: deploy/miren.sh <instance> <hostname> [admin-token]}"
-TOKEN="${3:-$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')}"
-CLUSTER="${MIREN_CLUSTER:-mycluster}"
-
-REPO="$(cd "$(dirname "$0")/.." && pwd)"
-TMP="$(mktemp -d)"
-trap 'rm -rf "$TMP"' EXIT
-
-# Build context = the source, with instance-specific app name + disk name.
-cp -R "$REPO"/. "$TMP"/
-# Trim the context before Miren uploads it. Docker applies .dockerignore at BUILD
-# time, but Miren uploads this dir FIRST — so a fat working tree makes every
-# deploy slow. Drop what the server image never needs: the native ios/ build
-# output (~700MB), the sibling Node apps' node_modules (~260MB), VCS/editor/agent
-# dirs, the local binary, and *.db (local SQLite holding user data — must never
-# leave the machine). Mirrors .dockerignore, plus node_modules everywhere.
-rm -rf "$TMP/.git" "$TMP/ios" "$TMP/.claude" "$TMP/lchat" \
- "$TMP"/*.db "$TMP"/*.db-wal "$TMP"/*.db-shm
-find "$TMP" -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
-sed -e "s/^name = \"lchat\"\$/name = \"$INSTANCE\"/" \
- -e "s/^name = \"lchat-data\"\$/name = \"${INSTANCE}-data\"/" \
- "$REPO/.miren/app.toml" > "$TMP/.miren/app.toml"
-
-echo "Deploying '$INSTANCE' (disk: ${INSTANCE}-data, host: $HOST, cluster: $CLUSTER)"
-# APNs for the native app: forward whichever transport the caller configured.
-# Relay mode (LCHAT_PUSH_RELAY_URL + _TOKEN) points the instance at
-# push.elevenmessenger.com and needs no .p8 here; direct mode forwards the raw
-# LCHAT_APNS_* signing key (legacy — the p8 lives on the box). The app prefers
-# relay when both are set, so an instance can migrate in two steps. All are
-# no-ops when unset.
-EXTRA=()
-for v in LCHAT_APNS_KEY LCHAT_APNS_KEY_ID LCHAT_APNS_TEAM_ID LCHAT_APNS_TOPIC \
- LCHAT_PUSH_RELAY_URL LCHAT_PUSH_RELAY_TOKEN; do
- [ -n "${!v:-}" ] && EXTRA+=(-e "$v=${!v}")
-done
-miren deploy -d "$TMP" -C "$CLUSTER" -f \
- -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN" ${EXTRA[@]+"${EXTRA[@]}"}
-miren route set "$HOST" "$INSTANCE" -C "$CLUSTER"
-
-URL="https://$HOST/admin#token=$TOKEN"
-command -v pbcopy >/dev/null 2>&1 && printf '%s' "$URL" | pbcopy && COPIED=" (copied to clipboard)" || COPIED=""
-echo
-echo "✅ '$INSTANCE' deployed and routed to $HOST"
-echo " Admin URL is https://$HOST/admin#token=…${COPIED}"
-echo " Recover the token anytime: miren env get LCHAT_ADMIN_TOKEN -u -a $INSTANCE -C $CLUSTER"
-echo " Last step: add DNS $HOST A -> <server IP> (TLS auto-provisions after)."
diff --git a/deploy/miren/agent.sh b/deploy/miren/agent.sh
deleted file mode 100755
index a7745bf..0000000
--- a/deploy/miren/agent.sh
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/env bash
-# Deploy the bot sidecar as its own Miren app, alongside (but separate from) an
-# lchat instance. Mirrors deploy/miren.sh: templates a throwaway app.toml so
-# git stays clean.
-#
-# Usage:
-# LCHAT_BASE=https://<host> [ANTHROPIC_API_KEY=sk-ant-...] [LCHAT_INVITE=<T>] \
-# ./deploy/miren/agent.sh <instance>
-#
-# <instance> -> app "<instance>-bot", disk "<instance>-bot-data"
-#
-# ANTHROPIC_API_KEY is OPTIONAL: omit it for a deterministic, bot-only sidecar —
-# Eleven still welcomes new members and answers slash commands, badges BOT (not
-# AI), and declines free-form questions until a key is set. With a key it
-# "upgrades" to conversational AI. (Claude is unaffected either way.)
-#
-# LCHAT_INVITE is only needed on the FIRST deploy (the sidecar self-provisions
-# and persists its session to the /data disk; later deploys ignore it).
-set -euo pipefail
-
-INSTANCE="${1:?usage: deploy/miren/agent.sh <instance>}"
-: "${LCHAT_BASE:?set LCHAT_BASE (e.g. https://test-bots.example.com)}"
-CLUSTER="${MIREN_CLUSTER:-mycluster}"
-APP="${INSTANCE}-bot"
-
-REPO="$(cd "$(dirname "$0")/../.." && pwd)"
-TMP="$(mktemp -d)"
-trap 'rm -rf "$TMP"' EXIT
-
-cp -R "$REPO"/. "$TMP"/
-# Trim the context before Miren uploads it (it uploads this dir FIRST, so a fat
-# working tree makes every deploy crawl). The agent image only needs agent/ +
-# web/static/crypto.js (deps are rebuilt via `npm ci` in Dockerfile.agent), so
-# drop the native ios/ build output (~700MB), node_modules everywhere, VCS/agent
-# dirs, the local binary, and *.db. Mirrors deploy/miren.sh.
-rm -rf "$TMP/.git" "$TMP/ios" "$TMP/.claude" "$TMP/lchat" \
- "$TMP"/*.db "$TMP"/*.db-wal "$TMP"/*.db-shm
-find "$TMP" -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
-sed "s/__APP__/$APP/g" "$REPO/.miren/agent.app.toml" > "$TMP/.miren/app.toml"
-
-ENVARGS=(-e "LCHAT_BASE=$LCHAT_BASE")
-# Optional: omit for a deterministic, bot-only sidecar (see header).
-[ -n "${ANTHROPIC_API_KEY:-}" ] && ENVARGS+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
-[ -n "${LCHAT_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_INVITE=$LCHAT_INVITE")
-[ -n "${LCHAT_AGENT_NAME:-}" ] && ENVARGS+=(-e "LCHAT_AGENT_NAME=$LCHAT_AGENT_NAME")
-[ -n "${LCHAT_MODEL:-}" ] && ENVARGS+=(-e "LCHAT_MODEL=$LCHAT_MODEL")
-# First-boot invites for the other default identities, like LCHAT_INVITE.
-[ -n "${LCHAT_ELEVEN_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_ELEVEN_INVITE=$LCHAT_ELEVEN_INVITE")
-
-echo "Deploying sidecar '$APP' (cluster $CLUSTER) -> $LCHAT_BASE"
-miren deploy -d "$TMP" -C "$CLUSTER" -f "${ENVARGS[@]}"
-echo "✅ sidecar '$APP' deployed. Logs: miren logs -a $APP -C $CLUSTER"
diff --git a/deploy/miren/home.sh b/deploy/miren/home.sh
deleted file mode 100755
index 749c9b6..0000000
--- a/deploy/miren/home.sh
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env bash
-# Deploy the home server (home/ — the anonymous passkey-gated backup store) as
-# its own Miren app. Mirrors deploy/miren.sh: templates a throwaway app.toml
-# in a temp dir so git stays clean; a plain redeploy PRESERVES the disk (never
-# delete the app to update it — that wipes users' backups).
-#
-# Usage: ./deploy/miren/home.sh <instance> <hostname>
-# ./deploy/miren/home.sh home home.elevenmessenger.com
-#
-# There is no admin token: the home server has no admin surface. The only
-# per-instance knob is LCHAT_HOME_ORIGIN (the WebAuthn Relying Party origin) —
-# derived from <hostname> here. NOTE: changing the hostname later orphans
-# existing passkeys (they're bound to the RP ID), so pick it once.
-set -euo pipefail
-
-INSTANCE="${1:?usage: deploy/miren/home.sh <instance> <hostname>}"
-HOST="${2:?usage: deploy/miren/home.sh <instance> <hostname>}"
-CLUSTER="${MIREN_CLUSTER:-mycluster}"
-
-REPO="$(cd "$(dirname "$0")/../.." && pwd)"
-TMP="$(mktemp -d)"
-trap 'rm -rf "$TMP"' EXIT
-
-# Build context = the source, with instance-specific app name + disk name.
-# The Dockerfile and app.toml live under home/ (keeping the messenger's
-# defaults untouched); stage them where Miren expects them.
-cp -R "$REPO"/. "$TMP"/
-# Trim the context before Miren uploads it (same reasoning as deploy/miren.sh:
-# Miren uploads this dir FIRST, so a fat working tree makes every deploy slow).
-# Drop what the home image never needs: the native ios/ build output (~700MB),
-# node_modules, VCS/editor/agent dirs, the local binary, and *.db — the local
-# SQLite holding user data, which must never leave the machine. home/ is kept
-# (it's the app being built). The build only compiles ./home from the shared
-# module, so none of these are needed.
-rm -rf "$TMP/.git" "$TMP/ios" "$TMP/.claude" "$TMP/lchat" \
- "$TMP"/*.db "$TMP"/*.db-wal "$TMP"/*.db-shm
-find "$TMP" -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
-cp "$REPO/home/Dockerfile" "$TMP/Dockerfile.home"
-sed -e "s/^name = \"home\"\$/name = \"$INSTANCE\"/" \
- -e "s/^name = \"home-data\"\$/name = \"${INSTANCE}-data\"/" \
- "$REPO/home/app.toml" > "$TMP/.miren/app.toml"
-
-echo "Deploying '$INSTANCE' (disk: ${INSTANCE}-data, host: $HOST, cluster: $CLUSTER)"
-miren deploy -d "$TMP" -C "$CLUSTER" -f \
- -e LCHAT_HOME_DB=/data/home.db -e "LCHAT_HOME_ORIGIN=https://$HOST"
-miren route set "$HOST" "$INSTANCE" -C "$CLUSTER"
-
-echo
-echo "✅ '$INSTANCE' deployed and routed to $HOST"
-echo " No admin token exists (nothing to store). Verify at https://$HOST/"
-echo " Last step (if needed): add DNS $HOST A -> <server IP> (TLS auto-provisions after)."
diff --git a/deploy/miren/push.sh b/deploy/miren/push.sh
deleted file mode 100755
index 0958a15..0000000
--- a/deploy/miren/push.sh
+++ /dev/null
@@ -1,63 +0,0 @@
-#!/usr/bin/env bash
-# Deploy the push relay (push/ — the central APNs signer) as its own Miren app.
-# Mirrors deploy/miren/home.sh: templates a throwaway app.toml in a temp dir so git
-# stays clean; a plain redeploy PRESERVES the disk (which holds the minted
-# per-instance tokens — never delete the app to update it).
-#
-# The Apple .p8 signing key lives ONLY on this app. It is read from the caller's
-# env (LCHAT_APNS_KEY + _KEY_ID / _TEAM_ID / _TOPIC — same names deploy/miren.sh
-# uses) and passed with `-e` (Miren doesn't inject `-s` sensitive vars into the
-# container). Pass LCHAT_APNS_KEY base64-packed; the relay decodes it.
-#
-# Usage: ./deploy/miren/push.sh <instance> <hostname> [admin-token]
-# ./deploy/miren/push.sh push push.elevenmessenger.com
-#
-# Reuse the admin token on redeploy (omitting it mints a new one, which would
-# invalidate the saved token). Recover it with:
-# miren env get LCHAT_ADMIN_TOKEN -u -a push -C <cluster>
-set -euo pipefail
-
-INSTANCE="${1:?usage: deploy/miren/push.sh <instance> <hostname> [admin-token]}"
-HOST="${2:?usage: deploy/miren/push.sh <instance> <hostname> [admin-token]}"
-TOKEN="${3:-$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')}"
-CLUSTER="${MIREN_CLUSTER:-mycluster}"
-
-for v in LCHAT_APNS_KEY LCHAT_APNS_KEY_ID LCHAT_APNS_TEAM_ID LCHAT_APNS_TOPIC; do
- [ -n "${!v:-}" ] || { echo "error: $v must be set (the .p8 signing config lives only on the relay)" >&2; exit 1; }
-done
-
-REPO="$(cd "$(dirname "$0")/../.." && pwd)"
-TMP="$(mktemp -d)"
-trap 'rm -rf "$TMP"' EXIT
-
-# Build context = the source, with instance-specific app + disk name. Trim the
-# upload context (same reasoning as deploy/miren/home.sh): drop the native ios/ build
-# output (~700MB), node_modules, VCS/editor/agent dirs, the local binary, and
-# *.db — the local SQLite holding data, which must never leave the machine.
-cp -R "$REPO"/. "$TMP"/
-rm -rf "$TMP/.git" "$TMP/ios" "$TMP/.claude" "$TMP/lchat" \
- "$TMP"/*.db "$TMP"/*.db-wal "$TMP"/*.db-shm
-find "$TMP" -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
-cp "$REPO/push/Dockerfile" "$TMP/Dockerfile.push"
-sed -e "s/^name = \"push\"\$/name = \"$INSTANCE\"/" \
- -e "s/^name = \"push-data\"\$/name = \"${INSTANCE}-data\"/" \
- "$REPO/push/app.toml" > "$TMP/.miren/app.toml"
-
-echo "Deploying '$INSTANCE' (disk: ${INSTANCE}-data, host: $HOST, cluster: $CLUSTER)"
-miren deploy -d "$TMP" -C "$CLUSTER" -f \
- -e LCHAT_PUSH_DB=/data/push.db \
- -e "LCHAT_ADMIN_TOKEN=$TOKEN" \
- -e "LCHAT_APNS_KEY=$LCHAT_APNS_KEY" \
- -e "LCHAT_APNS_KEY_ID=$LCHAT_APNS_KEY_ID" \
- -e "LCHAT_APNS_TEAM_ID=$LCHAT_APNS_TEAM_ID" \
- -e "LCHAT_APNS_TOPIC=$LCHAT_APNS_TOPIC"
-miren route set "$HOST" "$INSTANCE" -C "$CLUSTER"
-
-echo
-echo "✅ '$INSTANCE' deployed and routed to $HOST"
-echo " Recover the admin token: miren env get LCHAT_ADMIN_TOKEN -u -a $INSTANCE -C $CLUSTER"
-echo " Health check: https://$HOST/healthz"
-echo " Mint a per-instance token (store it in the instance's 1Password item):"
-echo " TOK=\$(miren env get LCHAT_ADMIN_TOKEN -u -a $INSTANCE -C $CLUSTER)"
-echo " curl -sX POST https://$HOST/api/admin/tokens -H \"Authorization: Bearer \$TOK\" -d '{\"label\":\"<host>\"}'"
-echo " Last step (if needed): add DNS $HOST A -> <server IP> (TLS auto-provisions after)."
diff --git a/docs/bots.md b/docs/bots.md
index 5e52af5..0e9d3d4 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -212,56 +212,41 @@ history to compose a reply (target: **mention-scoped**).
## One sidecar per instance, all bots in it
-Each instance runs **exactly one sidecar** — a Node worker deployed as its own
-Miren app, separate from the Go binary (that's a static distroless image with no
-Node runtime). That single sidecar drives **all of the instance's bots** (default
-+ custom): each bot is its own member identity with its own WebSocket connection,
-but one process, one host, one API key.
-
-- Node Dockerfile; build context includes `agent/` **and** `web/static/crypto.js`.
-- **Env:** `LCHAT_BASE` + `ANTHROPIC_API_KEY` — the sidecar's own Miren deploy
- env, the same channel as the app's operator secrets. The lchat server never
- sees the key. Per-bot config comes from the admin console (next section).
-- A small disk holds the sidecar's **keystore** — one keypair per bot — so a
- redeploy doesn't re-provision. Private keys never leave the host.
+Each instance runs **exactly one sidecar** — a separate headless process,
+never part of the server process. That single sidecar drives **all of the
+instance's bots** (default + custom): each bot is its own member identity with
+its own WebSocket connection, but one process, one host, one API key.
+
+- **Env:** `LCHAT_BASE` + `ANTHROPIC_API_KEY` — the sidecar's own deploy env,
+ the same channel as the app's operator secrets. The lchat server never sees
+ the key. Per-bot config comes from the admin console (next section).
+- A small data dir holds the sidecar's **keystore** — one keypair per bot — so
+ a redeploy doesn't re-provision. Private keys never leave the host.
- The sidecar **auto-provisions** each configured-but-new bot: generate a
keypair, register its pubkey, mark it `is_agent`, store the private key locally.
-- If Miren won't run a routeless worker, add a trivial `200 OK` health endpoint
- (the one thing to verify against the cluster before writing the Dockerfile).
-### Deploy (built and proven on `test-bots`)
+### Deploy
-Files: `Dockerfile.agent` (Node), `.miren/agent.app.toml` (templated per instance),
-`deploy/miren/agent.sh`. Deploy:
-
-```sh
-LCHAT_BASE=https://<host> ANTHROPIC_API_KEY=sk-ant-... LCHAT_INVITE=<T> \
- ./deploy/miren/agent.sh <instance> # -> app "<instance>-bot"
-```
-
-The app has a `[services.web]` with a health port (no route → no public hostname),
-a `/data` disk for the session, and self-provisions on first boot from
-`LCHAT_INVITE`. Three gotchas cost real time — worth remembering:
-
-1. **Miren launches the container with cwd `/app`, not the Dockerfile `WORKDIR`.**
- Use an absolute `CMD` (`node /app/agent/agent.mjs`) or you get `MODULE_NOT_FOUND`.
-2. **Node's async stdout is lost when a container is killed.** Log startup lines
- with `fs.writeSync(1, …)` or you debug blind. Also bind the health server to
- `0.0.0.0` explicitly.
-3. **Rapid redeploys of a disk-backed app strand the disk lease** — dead sandboxes
- hold it, so new ones fail to boot (`disk ... has an active lease`). Don't churn;
- if you do, `miren app delete <app> -f` then deploy once (recover
- `ANTHROPIC_API_KEY` from the app env first — it lives only there).
+On the fleet, `lchat add-bot <host>` provisions the identities and the
+`lchat-bot@<host>` systemd unit runs `lchat bot run` (the Go sidecar inside
+the instance's own versioned binary) with env in `/data/<host>-bot.env` and
+sessions in `/data/<host>-bot/`. The Node reference sidecar (`agent/`) runs
+the same way anywhere Node does: point `LCHAT_BASE` at the instance, feed it
+the agent invites, run `node agent/agent.mjs`. (The original deployment shape
+— a per-instance container app on the Miren platform, `Dockerfile.agent` —
+was retired 2026-07-15 along with that platform; the sidecar has no inbound
+ports, so any box that can dial out can host it.)
## Configuration & control
Split config by what it *is*:
-**The Anthropic API key → the sidecar's Miren env** (`ANTHROPIC_API_KEY`), the
-same channel as the app's operator secrets (`LCHAT_ADMIN_TOKEN`, `LCHAT_DB`).
-Whoever deploys the sidecar is the operator; they set it at deploy, rotate it by
-updating env, and recover it with `miren env get`. Crucially the **lchat server
-never touches the key in any form** — no store-key endpoint, no encryption dance.
+**The Anthropic API key → the sidecar's env** (`ANTHROPIC_API_KEY`, in
+`/data/<host>-bot.env` on the fleet), the same channel as the app's operator
+secrets (`LCHAT_ADMIN_TOKEN`, `LCHAT_DB`). Whoever deploys the sidecar is the
+operator; they set it at deploy and rotate it by updating env. Crucially the
+**lchat server never touches the key in any form** — no store-key endpoint, no
+encryption dance.
That's simpler than routing it through the console *and* strictly better for
"server blind": the server has zero involvement with the key, rather than holding
a wrapped blob of it. (An earlier draft put the key in the console encrypted to
diff --git a/docs/push-relay.md b/docs/push-relay.md
index 6b8a27b..2ddd34d 100644
--- a/docs/push-relay.md
+++ b/docs/push-relay.md
@@ -112,8 +112,9 @@ boot silently disabled. An empty token table is fine (mint after first deploy).
## Deploy
-`./deploy/miren/push.sh push push.elevenmessenger.com` (mirrors `deploy/miren/home.sh`;
-data-safe redeploys preserve the disk — never delete the app to update it). The
-`.p8` + admin token are read from the caller's env and passed with `-e`. DNS
-needs an explicit `push.elevenmessenger.com` A record (no wildcard on that zone,
-like `home`). Operator specifics live in `CLAUDE.local.md`.
+The relay runs as the `lchat-push` systemd service on the router box (unix
+socket, fronted by the router): build the `push` binary, copy it up,
+`systemctl restart lchat-push` — data-safe; the DB and the `.p8` + admin token
+env (`/data/push.env`) are never touched by a redeploy. DNS needs an explicit
+`push.elevenmessenger.com` A record (no wildcard on that zone, like `home`).
+Operator specifics live in `CLAUDE.local.md`.
diff --git a/home/Dockerfile b/home/Dockerfile
deleted file mode 100644
index 7cd56ab..0000000
--- a/home/Dockerfile
+++ /dev/null
@@ -1,21 +0,0 @@
-# The home server: single static Go binary, built from the repo root so the
-# shared go.mod resolves. deploy/miren/home.sh copies this file to the build context
-# root as "Dockerfile.home".
-
-# ---- build ----
-FROM golang:1.23-alpine AS build
-WORKDIR /src
-COPY go.mod go.sum ./
-RUN go mod download
-COPY . .
-# Pure-Go SQLite (modernc) means we can build a fully static binary with no cgo.
-RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /home-server ./home
-
-# ---- run ----
-# distroless/static: ~2MB, no shell, runs as root so it can write to the
-# mounted data volume. The frontend and SQLite engine are embedded.
-FROM gcr.io/distroless/static-debian12
-COPY --from=build /home-server /home-server
-# Runtime config comes from env: LCHAT_HOME_DB, LCHAT_HOME_ORIGIN, ADDR.
-EXPOSE 1748
-ENTRYPOINT ["/home-server"]
diff --git a/home/README.md b/home/README.md
index 93abca4..127cb48 100644
--- a/home/README.md
+++ b/home/README.md
@@ -85,9 +85,10 @@ go test ./home
```
`LCHAT_HOME_ORIGIN` is the public origin (the WebAuthn RP identity) — pick the
-hostname once; changing it orphans existing passkeys. Deploy with
-`./deploy/miren/home.sh <instance> <hostname>` (data-safe redeploys, same rules as
-`deploy/miren.sh`: never delete the app to update it).
+hostname once; changing it orphans existing passkeys. In production it runs as
+the `lchat-home` systemd service on the router box (unix socket, fronted by the
+router): build the `home` binary, copy it up, `systemctl restart lchat-home` —
+data-safe; the DB at `/data/home.db` is never touched by a redeploy.
### Optional: serving the planet-scale guide at /infrastructure/
diff --git a/home/app.toml b/home/app.toml
deleted file mode 100644
index 41f0adf..0000000
--- a/home/app.toml
+++ /dev/null
@@ -1,34 +0,0 @@
-# Miren deployment config template for the home server (see deploy/miren/home.sh,
-# which copies this to .miren/app.toml with the instance name substituted —
-# mirrors how deploy/miren.sh templates the messenger's app.toml).
-
-name = "home"
-
-[build]
-# deploy/miren/home.sh places home/Dockerfile at the context root under this name.
-dockerfile = "Dockerfile.home"
-
-[services.web]
-# Miren terminates TLS and routes external HTTPS to this container port.
-# The app listens on :1748 by default (or $ADDR), so these must match.
-port = 1748
-
-# A persistent disk requires fixed concurrency; single instance also suits
-# SQLite's single-writer model.
-[services.web.concurrency]
-mode = "fixed"
-num_instances = 1
-
-[[services.web.disks]]
-name = "home-data"
-mount_path = "/data"
-size_gb = 1
-
-[[env]]
-key = "LCHAT_HOME_DB"
-value = "/data/home.db"
-
-# LCHAT_HOME_ORIGIN (the public https origin — the WebAuthn RP identity) is set
-# at deploy time by deploy/miren/home.sh via `miren deploy -e`, since it depends on
-# the hostname being routed. There is deliberately NO admin token: the server
-# has no admin surface, and no key material to protect.
diff --git a/push/Dockerfile b/push/Dockerfile
deleted file mode 100644
index 40dfffd..0000000
--- a/push/Dockerfile
+++ /dev/null
@@ -1,21 +0,0 @@
-# The push relay: single static Go binary, built from the repo root so the
-# shared go.mod resolves. deploy/miren/push.sh copies this file to the build context
-# root as "Dockerfile.push".
-
-# ---- build ----
-FROM golang:1.23-alpine AS build
-WORKDIR /src
-COPY go.mod go.sum ./
-RUN go mod download
-COPY . .
-# Pure-Go SQLite (modernc) means we can build a fully static binary with no cgo.
-RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /push-relay ./push
-
-# ---- run ----
-# distroless/static: ~2MB, no shell. The SQLite engine is embedded; the only
-# state is the minted-token table on the mounted volume.
-FROM gcr.io/distroless/static-debian12
-COPY --from=build /push-relay /push-relay
-# Runtime config comes from env: LCHAT_PUSH_DB, LCHAT_ADMIN_TOKEN, LCHAT_APNS_*, ADDR.
-EXPOSE 1749
-ENTRYPOINT ["/push-relay"]
diff --git a/push/app.toml b/push/app.toml
deleted file mode 100644
index b115a56..0000000
--- a/push/app.toml
+++ /dev/null
@@ -1,34 +0,0 @@
-# Miren deployment config template for the push relay (see deploy/miren/push.sh, which
-# copies this to .miren/app.toml with the instance name substituted — mirrors
-# how deploy/miren/home.sh / deploy/miren.sh template their app.toml).
-
-name = "push"
-
-[build]
-# deploy/miren/push.sh places push/Dockerfile at the context root under this name.
-dockerfile = "Dockerfile.push"
-
-[services.web]
-# Miren terminates TLS and routes external HTTPS to this container port.
-# The relay listens on :1749 by default (or $ADDR), so these must match.
-port = 1749
-
-# A persistent disk requires fixed concurrency, and the disk suits SQLite's
-# single-writer model. NOTE: single instance makes the relay a single point of
-# failure for iOS push across the fleet — accepted because push is best-effort
-# and off the message-post path, and Web Push is unaffected (docs/push-relay.md).
-[services.web.concurrency]
-mode = "fixed"
-num_instances = 1
-
-[[services.web.disks]]
-name = "push-data"
-mount_path = "/data"
-size_gb = 1
-
-[[env]]
-key = "LCHAT_PUSH_DB"
-value = "/data/push.db"
-
-# LCHAT_ADMIN_TOKEN and the LCHAT_APNS_* signing key are secrets set at deploy
-# time by deploy/miren/push.sh via `miren deploy -e` (the .p8 lives ONLY on this app).