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>
17 files changed,
+96
−553
.miren/agent.app.toml+0 −40.miren/app.toml+0 −44CLAUDE.md+16 −9Dockerfile+13 −5Dockerfile.agent+0 −22README.md+31 −49deploy/miren.sh+0 −59deploy/miren/agent.sh+0 −52deploy/miren/home.sh+0 −51deploy/miren/push.sh+0 −63docs/bots.md+26 −41docs/push-relay.md+6 −5home/Dockerfile+0 −21home/README.md+4 −3home/app.toml+0 −34push/Dockerfile+0 −21push/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). |