eleven / messenger Public

Add Miren deployment (Dockerfile, app.toml, env-based config)

- Dockerfile: multi-stage, fully static (CGO disabled), ~11MB image
- .miren/app.toml: web service on :1747, persistent /data disk for SQLite,
  ADMIN_TOKEN secret + LCHAT_DB env
- main.go: DB path and listen address now configurable via env
  (LCHAT_DB, ADDR) so the launch command doesn't matter
- README: Miren deploy steps, domain wiring for group.luan.chat, DNS/TLS notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 165d68935a14a87ad7f1950a154189a0f90dea8f parent cce8475
5 files changed, +120 −2
  • .dockerignore +8 −0
  • .miren/app.toml +40 −0
  • Dockerfile +17 −0
  • README.md +46 −0
  • main.go +9 −2
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..1a36c44
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+.git
+.gitignore
+/lchat
+*.db
+*.db-wal
+*.db-shm
+.DS_Store
+README.md
diff --git a/.miren/app.toml b/.miren/app.toml
new file mode 100644
index 0000000..9e3cef6
--- /dev/null
+++ b/.miren/app.toml
@@ -0,0 +1,40 @@
+# 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
+
+# Persistent disk for the SQLite database. SQLite wants a single writer, which
+# lines up with Miren's note that disks use exclusive leasing on one instance —
+# so keep this service at a single replica.
+[[services.web.disks]]
+name = "lchat-data"
+mount_path = "/data"
+size_gb = 1
+
+[[env]]
+key = "LCHAT_DB"
+value = "/data/lchat.db"
+
+[[env]]
+# The admin token for the invite backend. It is NOT a decryption key — messages
+# stay end-to-end encrypted regardless. Set the value as a secret, then your
+# admin URL is: https://group.luan.chat/admin#token=<this value>
+key = "ADMIN_TOKEN"
+required = true
+sensitive = true
+description = "Admin token used in the /admin#token=... link"
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0378d60
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+# ---- 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 /lchat .
+
+# ---- 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
+COPY --from=build /lchat /lchat
+# Runtime config comes from env (see .miren/app.toml): LCHAT_DB, ADDR, ADMIN_TOKEN.
+EXPOSE 1747
+ENTRYPOINT ["/lchat"]
diff --git a/README.md b/README.md
index 510399e..bab4758 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,52 @@ Copy `lchat` to your server and run it behind a TLS-terminating reverse proxy
(Caddy/nginx). **HTTPS is required in production** — the invite token travels to
the server, and the fragment-key model assumes the transport is encrypted.
+## Deploy to Miren
+
+[Miren](https://miren.dev) builds the container, runs it, and handles routing +
+TLS, so you don't need a separate reverse proxy. This repo ships a `Dockerfile`
+and `.miren/app.toml` ready to go.
+
+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`)
+- `ADMIN_TOKEN` — admin token (set as a Miren secret)
+- `ADDR` — listen address (defaults to `:1747`, matching the declared port)
+
+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.
+
+```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. Set the admin token as a secret (URL-safe, since it goes in a URL fragment):
+openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' # generate one
+miren secret set ADMIN_TOKEN <the-value> # exact cmd may vary by version
+
+# 3. Deploy and attach the domain:
+miren deploy
+miren route set group.luan.chat lchat
+```
+
+Then point DNS for `group.luan.chat` 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.
+
+Your admin URL is then:
+
+```
+https://group.luan.chat/admin#token=<ADMIN_TOKEN>
+```
+
+> 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.
+
## Flow
1. **Admin** opens the admin link, gets the chat's encryption key (generated in
diff --git a/main.go b/main.go
index 04794e1..4700ad3 100644
--- a/main.go
+++ b/main.go
@@ -22,8 +22,8 @@ type Server struct {
}
func main() {
- addr := flag.String("addr", ":1747", "listen address")
- dbPath := flag.String("db", "lchat.db", "path to SQLite database file")
+ addr := flag.String("addr", envOr("ADDR", ":1747"), "listen address (env: ADDR)")
+ dbPath := flag.String("db", envOr("LCHAT_DB", "lchat.db"), "path to SQLite database file (env: LCHAT_DB)")
resetAdmin := flag.Bool("reset-admin", false, "generate a new admin token and print its link")
flag.Parse()
@@ -222,6 +222,13 @@ func httpError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]any{"error": msg})
}
+func envOr(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
+
func printAdminLink(addr, token string) {
host := addr
if strings.HasPrefix(host, ":") {