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>
7 files changed,
+242
−28
README.md+28 −21bootstrap.go+8 −1deploy/install.sh+77 −0deploy/release-server.sh+10 −0docs/deploying-instances.md+6 −3docs/instance-router.md+5 −3docs/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/`. |