eleven / messenger Public

deploy+docs: the self-hoster experience — install one-liner + public guide (#433)

* deploy+docs: install.sh one-liner + the self-hosting guide

- deploy/install.sh: root/systemd/arch guards, downloads latest stable for
  the box arch from the rolling release, sha256-verifies, installs to
  /opt/lchat/lchat (atomic), runs bootstrap; re-run = update + unit refresh
- docs/self-hosting.md: stranger-facing guide (prereqs, one-liner, first
  chat, what you get, honest limitations incl. native push + home-server
  default, backups, operating); published as the PUBLIC releases repo README
  (synced by release-server.sh publish — this source repo is private)
- README: self-hosted section leads with the one-liner; own-builds recipe
  fixed for the incoming/ dir + --pin
- doc drift: bootstrap doesn't set LimitNOFILE; add-instance prints the
  setup link first

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* deploy: bootstrap restarts the router; install.sh survives truncated curl|bash (review fixes)

enable --now on an already-active lchat-router is a no-op, so re-running
the installer replaced the binary but left the OLD router process serving
forever — restart covers both fresh and re-run. install.sh body now runs
from main() invoked on the last line, so a truncated pipe executes nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 31453c60ccb6965475957dd2ebaeaa6a3d6fa93b parent 98a9fd3
7 files changed, +242 −28
  • README.md +28 −21
  • bootstrap.go +8 −1
  • deploy/install.sh +77 −0
  • deploy/release-server.sh +10 −0
  • docs/deploying-instances.md +6 −3
  • docs/instance-router.md +5 −3
  • docs/self-hosting.md +108 −0
diff --git a/README.md b/README.md
index ff1c508..d608ae2 100644
--- a/README.md
+++ b/README.md
@@ -156,36 +156,43 @@ not replicas of one).
How we run it, and the default for hosting **one or many** chats: a single box
runs lchat's own built-in TLS router, and each chat is a fully-isolated instance
on its own subdomain, database, and admin token — automatic Let's Encrypt certs,
-no external reverse proxy, no container platform. Because the server is one
-static binary, the whole dependency story is "copy the binary."
+no external reverse proxy, no container platform. One line installs the latest
+stable release (sha256-verified; amd64 or arm64) and bootstraps the box:
```sh
-# on a fresh box (Ubuntu; inbound 80 + 443 open), as root:
-scp lchat root@box:/opt/lchat/lchat
-ssh root@box /opt/lchat/lchat bootstrap # installs the systemd units + starts the router
-
-# add a chat — point its DNS A record at the box first; the TLS cert then
-# issues automatically on the first HTTPS request:
-ssh root@box /opt/lchat/lchat add-instance chat.example.com
-ssh root@box /opt/lchat/lchat admin-link chat.example.com # prints its admin URL
+# on a fresh box (Ubuntu/Debian; inbound 80 + 443 open):
+curl -fsSL https://github.com/elevenmessenger/releases/releases/download/server/install.sh | sudo bash
+
+# add a chat — point its DNS A record at the box first; the TLS cert issues
+# automatically, and the printed setup link makes you the admin:
+lchat add-instance chat.example.com
```
-Add as many chats as you like; each is isolated. Updates are **versioned and
-zero-downtime** — build a binary, stage it, then roll it out to one instance
-(canary) or the whole fleet, with a graceful drain and an automatic rollback if
-the new build fails its health check:
+Add as many chats as you like; each is isolated. The box then **keeps itself up
+to date** from the signed stable release channel — hourly checks, per-instance
+health check, automatic rollback — and each chat's admin picks a channel
+(stable / beta / edge) in their admin panel. The full guide (backups, bots,
+honest limitations) is **[docs/self-hosting.md](docs/self-hosting.md)**,
+published as the README of the public
+[releases repo](https://github.com/elevenmessenger/releases).
+
+Deploying your **own builds** instead (development, forks): build, stage, then
+roll out — zero-downtime, health-checked, auto-rollback; pin canaries so the
+auto-updater leaves them alone:
```sh
./deploy/build-linux.sh # builds + stamps a version id
-scp lchat-linux-amd64 root@box:/opt/lchat/incoming
-ssh root@box /opt/lchat/lchat stage --file /opt/lchat/incoming
-ssh root@box /opt/lchat/lchat deploy chat.example.com --version <ver> # one instance
-ssh root@box /opt/lchat/lchat deploy --all --version <ver> # the whole fleet
-ssh root@box /opt/lchat/lchat rollback chat.example.com # step back
+scp lchat-linux-amd64 root@box:/opt/lchat/incoming/lchat-upload
+ssh root@box /opt/lchat/lchat stage --file /opt/lchat/incoming/lchat-upload
+ssh root@box /opt/lchat/lchat deploy chat.example.com --version <ver> --pin # canary, held
+ssh root@box /opt/lchat/lchat deploy --all --version <ver> # the whole fleet
+ssh root@box /opt/lchat/lchat rollback chat.example.com # step back
```
-The full design — routing, supervision (systemd), backups, and hardening — is in
-**[docs/instance-router.md](docs/instance-router.md)**.
+The full design — routing, supervision (systemd), releases + auto-update,
+backups, and hardening — is in
+**[docs/instance-router.md](docs/instance-router.md)** and
+**[docs/server-releases.md](docs/server-releases.md)**.
### A single instance behind your own reverse proxy
diff --git a/bootstrap.go b/bootstrap.go
index 3064b5f..c0b395c 100644
--- a/bootstrap.go
+++ b/bootstrap.go
@@ -168,7 +168,14 @@ func runBootstrap(args []string) {
fmt.Println("bootstrap: units installed (router not started; --no-start)")
return
}
- if err := systemctl("enable", "--now", "lchat-router"); err != nil {
+ if err := systemctl("enable", "lchat-router"); err != nil {
+ die("bootstrap: enable router: %v", err)
+ }
+ // restart, not `enable --now`: on a re-run over a live box (the installer's
+ // update path) `--now` is a no-op for an already-active unit, silently
+ // leaving the OLD router process serving the new binary's units. restart
+ // also starts an inactive unit, so the fresh-box path is unchanged.
+ if err := systemctl("restart", "lchat-router"); err != nil {
die("bootstrap: start router: %v", err)
}
if err := systemctl("enable", "--now", "lchat-update.timer"); err != nil {
diff --git a/deploy/install.sh b/deploy/install.sh
new file mode 100755
index 0000000..386d563
--- /dev/null
+++ b/deploy/install.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# Install (or update) the lchat server on this box.
+#
+# curl -fsSL https://github.com/elevenmessenger/releases/releases/download/server/install.sh | sudo bash
+#
+# Downloads the latest STABLE release for this machine's architecture from the
+# public releases repo, verifies its sha256 against the published checksum,
+# installs it at /opt/lchat/lchat, and runs `lchat bootstrap` (idempotent: it
+# installs/refreshes the systemd units, creates /data, and enables the router
+# and the hourly auto-update timer). Re-running this script updates the binary
+# and refreshes the units; it never touches /data, so it's safe on a live box.
+#
+# After install, the box keeps itself current from the stable channel — see
+# docs/self-hosting.md in the elevenmessenger/messenger repo.
+set -euo pipefail
+
+REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}"
+BASE="https://github.com/$REPO/releases/download/server"
+
+say() { printf '%s\n' "$*"; }
+fail() { printf 'install: %s\n' "$*" >&2; exit 1; }
+
+# Everything runs from main(), invoked on the last line — so a truncated
+# `curl | bash` transfer fails to parse and executes NOTHING, instead of
+# running whatever prefix happened to arrive.
+main() {
+
+[ "$(id -u)" = 0 ] || fail "must run as root (it installs systemd units and creates the service user) — re-run with sudo"
+[ -d /run/systemd/system ] || fail "this box isn't running systemd — lchat's supervision, zero-downtime deploys, and auto-updates are all systemd units (Ubuntu and Debian are the tested platforms)"
+[ -f /etc/debian_version ] || say "note: not a Debian-family system — Ubuntu/Debian are what we test on; continuing anyway"
+
+case "$(uname -m)" in
+x86_64) ARCH=amd64 ;;
+aarch64) ARCH=arm64 ;;
+*) fail "unsupported architecture $(uname -m) — builds are published for x86_64 (amd64) and aarch64 (arm64)" ;;
+esac
+BIN="lchat-linux-$ARCH"
+
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+say "Downloading the latest stable lchat ($ARCH)..."
+curl -fsSL "$BASE/$BIN" -o "$TMP/$BIN" || fail "download failed — is $BASE/$BIN reachable?"
+curl -fsSL "$BASE/$BIN.sha256" -o "$TMP/$BIN.sha256" || fail "checksum download failed"
+(cd "$TMP" && sha256sum -c "$BIN.sha256" >/dev/null) || fail "sha256 verification FAILED — refusing to install (a truncated download, or something is tampering with the download path)"
+
+# Temp + rename on the same filesystem, so a crash mid-install never leaves a
+# half-written binary and a re-run always lands atomically over the old one.
+mkdir -p /opt/lchat
+install -m 0755 "$TMP/$BIN" /opt/lchat/lchat.new
+mv /opt/lchat/lchat.new /opt/lchat/lchat
+say "Installed $(/opt/lchat/lchat version) at /opt/lchat/lchat"
+
+say "Running bootstrap (systemd units, /data, router, auto-update timer)..."
+/opt/lchat/lchat bootstrap
+
+cat <<'EOF'
+
+Done. Next:
+
+ 1. Point a DNS A record at this box, e.g. chat.example.com -> this IP
+ (ports 80 and 443 must be open — TLS certs issue automatically).
+ 2. Create your chat:
+
+ lchat add-instance chat.example.com
+
+ It prints a one-click setup link — open it, and you're the admin.
+
+Add as many chats as you like (each is fully isolated). This box keeps itself
+up to date from the stable channel automatically; see `lchat update status`.
+Everything that matters lives in /data — back that up.
+
+Guide: https://github.com/elevenmessenger/releases#readme
+EOF
+
+}
+main "$@"
diff --git a/deploy/release-server.sh b/deploy/release-server.sh
index bcc55d6..cce77ee 100755
--- a/deploy/release-server.sh
+++ b/deploy/release-server.sh
@@ -90,6 +90,16 @@ publish)
if [ -f deploy/install.sh ]; then
gh release upload server deploy/install.sh -R "$REPO" --clobber
fi
+ # The self-hosting guide is the public downloads repo's README (this source
+ # repo is private, so strangers can't read docs/ here) — synced on publish.
+ if [ -f docs/self-hosting.md ]; then
+ README_SHA="$(gh api "repos/$REPO/contents/README.md" --jq .sha 2>/dev/null || true)"
+ gh api -X PUT "repos/$REPO/contents/README.md" \
+ -f message="sync self-hosting guide (messenger @$VER)" \
+ -f content="$(base64 < docs/self-hosting.md | tr -d '\n')" \
+ ${README_SHA:+-f sha="$README_SHA"} >/dev/null
+ echo "synced self-hosting guide -> $REPO README"
+ fi
echo "published server-$VER and pointed edge at it"
;;
diff --git a/docs/deploying-instances.md b/docs/deploying-instances.md
index d2f4569..a71d283 100644
--- a/docs/deploying-instances.md
+++ b/docs/deploying-instances.md
@@ -69,12 +69,15 @@ ssh root@<box> /opt/lchat/lchat add-instance chat.example.com
It mints an admin token, writes `/data/chat.example.com.env`, registers the
route, stages the current binary and points `live/<host>` at it, starts
`lchat@chat.example.com`, waits for the socket to answer, reloads the router,
-and prints the admin link:
+and prints a one-time setup link (open it and you're the admin) plus the
+admin-token link:
```
added chat.example.com
- admin: https://chat.example.com/admin#token=…
- (recover later: lchat admin-link chat.example.com)
+ get started (one-time link, makes you the admin):
+ https://chat.example.com/join/setup#…
+ admin token link: https://chat.example.com/admin#token=…
+ (recover later: lchat admin-link chat.example.com / lchat setup-invite chat.example.com)
```
Optional flags (all become vars in the instance's env file):
diff --git a/docs/instance-router.md b/docs/instance-router.md
index 821ecbe..09e90ac 100644
--- a/docs/instance-router.md
+++ b/docs/instance-router.md
@@ -318,10 +318,12 @@ toolchain and no language runtime** for the messenger, the router, or the
home/push services — provisioning is just "copy binaries + unit files + make
directories":
-1. `scp` the `lchat` binary (plus `home`/`push` and `litestream`) to the box.
+1. `scp` the `lchat` binary (plus `home`/`push` and `litestream`) to the box —
+ or run `deploy/install.sh`, which downloads + verifies the latest stable
+ release and does step 2 for you.
2. `lchat bootstrap` — writes the systemd units (router + `@` templates), creates
- `/data`, `/run/lchat`, and the autocert cache dir, sets `LimitNOFILE`, then
- enables and starts the router.
+ `/data`, `/run/lchat`, and the autocert cache dir, then enables and starts
+ the router and the auto-update timer.
3. Open inbound `:80` / `:443`.
That's the whole dependency story for the Go side — nothing to `apt install`, no
diff --git a/docs/self-hosting.md b/docs/self-hosting.md
new file mode 100644
index 0000000..3e67fd5
--- /dev/null
+++ b/docs/self-hosting.md
@@ -0,0 +1,108 @@
+# 🤖 Self-hosting Eleven
+
+Run your own Eleven server: end-to-end-encrypted group chat where **your box
+never sees a message** — the server stores only ciphertext, and every key stays
+on your members' devices. One static binary, one directory of state, automatic
+TLS, automatic updates.
+
+> This page is published as the README of
+> [`elevenmessenger/releases`](https://github.com/elevenmessenger/releases)
+> (the public downloads repo). Its source of truth is `docs/self-hosting.md`
+> in the main repo.
+
+## What you need
+
+- A Linux box: **Ubuntu or Debian** with systemd (what we test on), `x86_64`
+ or `arm64`, 1 GB RAM is plenty to start. A €4/month VPS works.
+- **Root** on it, with inbound ports **80 and 443** open.
+- A **domain** (or subdomain) you can point at the box.
+
+## Install
+
+```sh
+curl -fsSL https://github.com/elevenmessenger/releases/releases/download/server/install.sh | sudo bash
+```
+
+That downloads the latest **stable** release for your architecture, verifies
+its published sha256, installs it at `/opt/lchat/lchat`, and bootstraps the
+box: a locked-down service user, systemd units, the TLS router on 80/443, and
+the hourly auto-update timer. (Prefer not to pipe to shell? Download
+`install.sh` from the same URL, read it — it's short — then run it.)
+
+## Create your first chat
+
+Point a DNS **A record** at the box (`chat.example.com → your box IP`), then:
+
+```sh
+lchat add-instance chat.example.com
+```
+
+It prints a one-click **setup link**. Open it: you pick a name, your browser
+generates your keys (they never leave it), and Eleven walks you through naming
+the space and inviting people. The Let's Encrypt certificate issues
+automatically on the first request — no certbot, no reverse proxy.
+
+Each `add-instance` creates a **fully isolated** chat: its own database, admin
+token, and subdomain. Run one for the family and another for the club; they
+share nothing.
+
+## What you get out of the box
+
+- **End-to-end encryption**: messages, names, photos, and files are encrypted
+ on devices; the server relays and stores ciphertext. If your box is
+ compromised, the attacker gets nothing readable.
+- **Web app + notifications**: members use it from any browser; web push
+ notifications work with zero configuration.
+- **Automatic updates**: the box checks our signed release feed hourly and
+ applies **stable**-channel updates with a health check and automatic
+ rollback. Each chat's admin can change channel (stable/beta/edge) or turn
+ auto-updates off in the admin panel; `lchat update status` shows the fleet.
+- **Bots, optionally with AI**: `lchat bot provision` + `lchat bot run` (same
+ binary, no other runtime) adds the built-in helpers — Eleven welcomes new
+ members and answers slash commands. Set `ANTHROPIC_API_KEY` and they get a
+ brain; without it they're deterministic helpers. See `docs/bots.md`.
+
+## Honest limitations
+
+- **Native app push**: members can use the Eleven iOS/Mac apps against your
+ server, but native *push notifications* route through Apple via our central
+ relay, which needs a per-server token we mint. Web push is unaffected. If
+ you want native push for your server, ask us for a relay token (open an
+ issue on this repo) — self-serve is planned.
+- **Passkey backup routes through us by default**: the optional
+ "save a passkey" account backup enrolls against our home server
+ (`home.elevenmessenger.com`). What's stored there is passkey-encrypted —
+ we can't read it — but if you'd rather keep even that ciphertext on your own
+ hardware, run the `home` service yourself and set `LCHAT_HOME_ORIGIN` in
+ the instance's `/data/<host>.env`.
+- **The trust root for updates is ours**: auto-updates install binaries we
+ sign. That's the point of the channels — but if you fork, build your own
+ binary with your own key and feed URL (`LCHAT_UPDATE_MANIFEST_URL`).
+
+## Operating it
+
+- **Everything lives in `/data`** — databases, TLS certs, tokens, env files.
+ Back that directory up and you can rebuild the box from scratch. (For
+ continuous backups, [Litestream](https://litestream.io) replicating
+ `/data/*.db` to object storage works well; the installer doesn't set this
+ up for you.)
+- **Updates**: automatic. `lchat update status` shows every chat's channel and
+ version; `lchat update run --dry-run` previews a pass; a bad release rolls
+ itself back. Re-running the install one-liner also updates immediately and
+ refreshes the systemd units.
+- **Admin access**: `lchat admin-link chat.example.com` re-prints a chat's
+ admin URL (it's also in `/data/<host>.env`).
+- **Remove a chat**: `lchat remove chat.example.com --stop` stops it and
+ unroutes it; its database stays in `/data` until you delete it yourself —
+ removal never destroys data.
+- **Hardening**: the units are already sandboxed; for the box itself, a
+ firewall (22/80/443), key-only SSH, and unattended OS upgrades are the
+ standard trio.
+
+## Scaling up (optional)
+
+The same binary contains the multi-tenant machinery we run
+[11ch.at](https://11ch.at) with — a self-serve provisioner, wildcard
+DNS-01 certificates (`LCHAT_WILDCARD_DOMAINS` + a DNSimple token), and a
+central home server. None of it is needed for a personal box; the design
+docs live in the main repo under `docs/`.