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>
5 files changed,
+120
−2
.dockerignore+8 −0.miren/app.toml+40 −0Dockerfile+17 −0README.md+46 −0main.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, ":") { |