From ca903d299433cf5d0183548d63e6b55717cb9892 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 27 Aug 2026 16:35:30 +0200 Subject: [PATCH] docs: add enterprise deployment guide Covers what the enterprise build adds, the reference architecture and its dependencies, the full environment-variable reference, multi-replica behaviour, hardening, upgrades and backups. --- docs/content/docs/enterprise.mdx | 363 +++++++++++++++++++++++++++++++ docs/content/docs/meta.json | 2 + 2 files changed, 365 insertions(+) create mode 100644 docs/content/docs/enterprise.mdx diff --git a/docs/content/docs/enterprise.mdx b/docs/content/docs/enterprise.mdx new file mode 100644 index 00000000..61053f72 --- /dev/null +++ b/docs/content/docs/enterprise.mdx @@ -0,0 +1,363 @@ +--- +title: Enterprise Deployment +description: Architecture, configuration and operations for running Lunogram Enterprise +--- + + + This page describes the Enterprise distribution. Everything outside [What + Enterprise adds](#what-enterprise-adds) applies to the open-source edition + too. + + +Lunogram Enterprise is the same platform as the open-source edition, built with +the `enterprise` build tag and shipped as a container image. There is no licence +key and no runtime activation: the features below are present because the image +is the Enterprise image. + +## What Enterprise adds + +| Feature | Surface | +| ------------------------------------------ | ---------------------------------------------- | +| [Broadcasts](/campaigns/broadcasts) | Send a campaign to an entire list at once | +| Team invites | Invite admins to an organization and assign roles | +| Sending domains | DNS-verified sending domains, backed by the courier service | +| Visual block editor | Drag-and-drop email editing next to the code editor | +| AI template builder | Generate and edit email templates from a prompt, backed by the backoffice service | + +Sending domains and the AI builder call two extra services — `courier` and +`backoffice` — which the platform reverse-proxies under `/courier/*` and +`/backoffice/*`. See [Enterprise services](#enterprise-services). + +## Architecture + + LB[Ingress / load balancer] + LB --> App[lunogram] + App --> PG[(PostgreSQL)] + App --> Redis[(Redis)] + App --> NATS[NATS JetStream] + App --> Blob[(Object storage)] + App -.-> Courier[courier] + App -.-> Backoffice[backoffice] + NATS --> Renderer[renderer] + Renderer --> NATS`} /> + +| Component | Role | Stateless | +| ---------------- | ----------------------------------------------------------------------------------------- | --------- | +| `lunogram` | API, console, event consumers and the scheduler. Every replica runs all four. | Yes | +| `renderer` | Deno service that compiles and renders React Email templates. Reached over NATS, not HTTP. | Yes | +| `courier` | Sending-domain management and DNS verification. Enterprise only. | No — own database | +| `backoffice` | AI template generation. Enterprise only. | No — own database | + +The `lunogram` binary is a single process; there is no separate worker +deployment. Adding replicas adds API capacity, consumer capacity and scheduler +capacity together. + +## Requirements + +| Dependency | Version | Notes | +| ------------------- | ---------------------- | ---------------------------------------------------------- | +| PostgreSQL | 18 | Four databases, see below | +| Redis | 8 | Leader election, rate limiting, caches. Dragonfly works too | +| NATS | 2.12 with JetStream | JetStream must be enabled — the platform will not start without it | +| Object storage | S3-compatible or Azure Blob | Required for more than one replica. See [Media Storage](/storage) | + +### Databases + +Lunogram uses four PostgreSQL databases. They must exist before first boot; +Lunogram creates the schema, not the databases. + +| Database | Contents | Variable | +| ------------ | ------------------------------------------------------- | ------------------------- | +| `management` | Organizations, admins, projects, campaigns, integrations | `POSTGRES_MANAGEMENT_URI` | +| `subjects` | Users, organizations-as-subjects, events, lists | `POSTGRES_SUBJECTS_URI` | +| `journey` | Journey definitions and per-user journey state | `POSTGRES_JOURNEY_URI` | +| `rbac` | Authorization tuples, read and written by the embedded OpenFGA engine | `RBAC_POSTGRES_URI` | + +They can live on one server or four. `subjects` carries the write volume — event +ingestion and list recomputation — and is the one to give dedicated resources if +you separate them. + + + OpenFGA runs in-process. There is no separate OpenFGA deployment to operate; + it only needs the `rbac` database. + + +Each replica opens up to 25 connections to each of `management`, `subjects` and +`journey`. Size `max_connections` (or your pooler) for at least +`75 × replicas`, plus headroom for the RBAC engine and for migrations. + +## Deploying + +The platform listens on `:8080` for HTTP by default (`HTTP_ADDRESS`). It serves +the console, the management API, the client API and the OpenAPI documents from +that single port. Nothing else needs to be exposed. + +A minimal Kubernetes deployment: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lunogram +spec: + replicas: 3 + selector: + matchLabels: + app.kubernetes.io/name: lunogram + template: + metadata: + labels: + app.kubernetes.io/name: lunogram + spec: + containers: + - name: lunogram + image: /lunogram: + ports: + - name: http + containerPort: 8080 + startupProbe: + httpGet: { path: /, port: http } + periodSeconds: 5 + failureThreshold: 60 + readinessProbe: + httpGet: { path: /, port: http } + periodSeconds: 10 + livenessProbe: + httpGet: { path: /, port: http } + periodSeconds: 10 + envFrom: + - secretRef: + name: lunogram +``` + +### Probes + +There is no dedicated health endpoint. Probe `GET /`, which serves the console +and returns `200` as soon as the server is listening. + +That is a meaningful signal: the HTTP server is started last, after migrations +have run and after PostgreSQL, object storage, NATS, the integration registry, +Redis and the RBAC engine have all been initialised. A node that answers has +completed its startup sequence. Use a generous `startupProbe` — the first boot +of a new version runs migrations before it listens. + +## Configuration + +Everything is configured through environment variables. Values below are the +defaults. + +### Core + +| Variable | Default | Description | +| ------------------ | ----------------------- | --------------------------------------------------------------- | +| `HTTP_ADDRESS` | `:8080` | Listen address. | +| `PUBLIC_URL` | `http://localhost:8080` | Externally reachable base URL. Used to build links in messages — preference centre, unsubscribe, click tracking. | +| `NODE_ID` | derived from hostname | Cluster identity. Leave unset. | +| `DATABASE_MIGRATE` | `true` | Run migrations at boot. See [Upgrades](#upgrades). | + +### Redis + +| Variable | Default | Description | +| ------------------ | ------------------------ | -------------------------------------------------------------- | +| `REDIS_ADDRESS` | `redis://127.0.0.1:6379` | Connection URL. | +| `REDIS_KEY_PREFIX` | — | Prefix for every key. Set it when several Lunogram installations share one Redis. | + +### NATS + +| Variable | Default | Description | +| ------------------------ | ------------------------ | ----------------------------------------------------------------- | +| `NATS_URL` | `nats://127.0.0.1:4222` | Connection URL. | +| `NATS_NAMESPACE` | — | Prefix for stream and consumer names. Set it when several installations share one NATS cluster. | +| `NATS_MANAGED_EXTERNALLY` | `false` | When `true`, Lunogram creates missing streams and consumers but never updates existing ones. Use it when JetStream resources are managed by your own tooling. | + +### Authentication + +| Variable | Default | Description | +| ----------------------------------- | -------------------------------- | -------------------------------------------------------------- | +| `AUTH_DRIVER` | — | `basic` or `clerk`. Required. | +| `AUTH_CONSOLE_SIGNING_KEY` | — | PEM-encoded EC P-256 private key signing console sessions (ES256). Required. | +| `AUTH_CONSOLE_PREVIOUS_SIGNING_KEYS` | — | Comma-separated retired keys that must still verify, so a rotation does not log everyone out. | +| `AUTH_CONSOLE_IDLE_TTL` | `8h` | How long a console session survives without being refreshed. | +| `AUTH_CONSOLE_ABSOLUTE_TTL` | `168h` | Hard cap on a console session's life, however often it is refreshed. | +| `AUTH_SESSION_SIGNING_KEY` | — | Separate EC P-256 key for the short-lived client session tokens your backend mints for SDKs. Leave unset to disable client sessions. | + +Generate a key with: + +```bash +openssl ecparam -name prime256v1 -genkey -noout +``` + + + `AUTH_CONSOLE_SIGNING_KEY` has no fallback: the server refuses to start + without it. This is deliberate. An auto-generated key would log every admin + out on restart, and replicas could not verify each other's sessions. + + +#### Basic + +A single built-in administrator. Suitable for a trial, not for a team. + +| Variable | Description | +| --------------------- | ------------------ | +| `AUTH_BASIC_EMAIL` | Administrator email. | +| `AUTH_BASIC_PASSWORD` | Administrator password. | + +#### Clerk + +The driver for SSO. [Clerk](https://clerk.com) fronts SAML and OpenID Connect, +so your identity provider is configured there rather than in Lunogram. Admins +are provisioned on first login, or ahead of it by the `user.created` webhook. + +| Variable | Description | +| --------------------------- | ------------------------------------------------------------------------------- | +| `AUTH_CLERK_SECRET_KEY` | Clerk secret key. | +| `AUTH_CLERK_WEBHOOK_SECRET` | Signing secret for the Clerk webhook. Payloads that fail verification are rejected. | +| `AUTH_CLERK_ISSUER` | The `iss` your Clerk instance stamps on tokens. Needed only for webhooks, whose payloads carry no issuer. | +| `AUTH_JWKS_URL` | JWKS endpoint used to verify session tokens. | +| `CONSOLE_CLERK_PUBLISHABLE_KEY` | Publishable key injected into the console at runtime, so one image serves every environment. | + +### Enterprise services + +Proxy routes are registered only for the services you configure. Leave a +variable unset and its route does not exist. + +| Variable | Description | +| ---------------------- | ----------------------------------------------------------------- | +| `PROXY_COURIER_URL` | Upstream for the courier service, exposed at `/courier/*`. | +| `PROXY_BACKOFFICE_URL` | Upstream for the backoffice service, exposed at `/backoffice/*`. | + +Both services hold their own PostgreSQL database and are reached over the +cluster network only — do not expose them directly. + +### HTTP and CSRF + +| Variable | Default | Description | +| -------------------------- | ---------------- | ---------------------------------------------------------------- | +| `READ_HEADER_TIMEOUT` | `5s` | Deadline for reading request headers. | +| `READ_TIMEOUT` | `5s` | Deadline for reading the whole request. | +| `WRITE_TIMEOUT` | `10s` | Deadline for writing the response. | +| `IDLE_TIMEOUT` | `5s` | Keep-alive idle deadline. | +| `MAX_HEADER_BYTES` | `1048576` | Largest accepted header block. | +| `CSRF_ENABLED` | `true` | CSRF protection for cookie-authenticated requests. | +| `CSRF_SECRET` | random per boot | Set it explicitly for more than one replica, otherwise a token minted by one node fails on another. | +| `CSRF_COOKIE_SECURE` | `true` | Requires HTTPS. Only turn this off for local development. | +| `CSRF_COOKIE_SAME_SITE` | `strict` | `strict`, `lax`, `none` or `default`. | +| `CSRF_TRUSTED_ORIGINS` | — | Extra origins allowed to submit. Empty means same-origin only. | + +### Rate limiting + +| Variable | Default | Description | +| ------------------------------ | ------- | -------------------------------------------------------------------- | +| `RATE_LIMIT_PER_MINUTE` | `600` | Requests per minute per API key, or per IP when unauthenticated. The budget is shared across the client and management APIs. | +| `RATE_LIMIT_TRUSTED_PROXY_HOPS` | `0` | How many reverse proxies sit in front. `X-Forwarded-For` is honoured only up to this many hops. | + + + `RATE_LIMIT_TRUSTED_PROXY_HOPS` defaults to `0`, which ignores + `X-Forwarded-For` entirely and rate-limits on the connection's remote + address. Behind a load balancer that means every unauthenticated request + shares one bucket. Set it to the real number of hops — and no higher, or a + client can forge its own address. + + +### Cluster + +Leader-elected work — journey resumptions, scheduled messages, broadcasts, list +recomputation — is driven by a single elected node. These knobs trade latency +against load. + +| Variable | Default | Description | +| ----------------------------------- | ------- | ------------------------------------------------------------------ | +| `CLUSTER_RECONCILIATION_INTERVAL` | `1m` | How often the leader scans for due work. | +| `CLUSTER_RECONCILIATION_BATCH_SIZE` | `1000` | Rows processed per task per tick. Remaining work rolls into the next tick. | +| `CLUSTER_LEADER_CAMPAIGN_INTERVAL` | `5s` | How often a node tries to take the leader lock. | +| `CLUSTER_HEARTBEAT_INTERVAL` | `4s` | Node liveness heartbeat. Nodes are evicted after 10s of silence. | + +### Link tracking + +| Variable | Default | Description | +| ------------------- | -------------- | -------------------------------------------------------------------- | +| `LINK_SECRET` | — | 32-byte key encrypting click-tracking tokens (AES-256-GCM). Link wrapping is inactive without it. | +| `LINK_TRACKING_URL` | `PUBLIC_URL` | Public base URL for the redirect endpoint, e.g. `https://t.example.com`. | + +### Other + +| Variable | Default | Description | +| --------------------------- | --------- | ------------------------------------------------------------------ | +| `WASM_CALL_TIMEOUT` | `30s` | Deadline for a single provider or action module call. | +| `WEBHOOK_MAX_BODY_SIZE` | `1048576` | Largest accepted inbound provider webhook payload, in bytes. | +| `WEBHOOK_CONFIG_FILE` | — | Path to the YAML file declaring outbound hooks. | +| `JWKS_CACHE_TTL` | `5m` | How long a fetched JWKS is cached. | +| `JWKS_CACHE_FETCH_TIMEOUT` | `5s` | Deadline for a single JWKS fetch. | +| `JWKS_CACHE_ERROR_TTL` | `30s` | Backoff before retrying an issuer whose JWKS fetch failed. | + +Media storage has its own page: [Media Storage](/storage). + +## Running multiple replicas + +Replicas are interchangeable. Coordination happens through Redis and NATS, not +between nodes directly, so there is no peer discovery to configure and no +minimum quorum. + +- **Leader election** uses a Redis lock with a 10-second TTL. Exactly one node + holds it; the rest stand by and take over within a few seconds if the leader + disappears. Scheduled work is not duplicated. +- **Event consumers** run on every node. NATS JetStream distributes messages + across them, so consumer throughput scales with replica count. +- **Sessions and CSRF** must verify on any node. That means every replica needs + the same `AUTH_CONSOLE_SIGNING_KEY` and an explicit `CSRF_SECRET`. +- **Media** must be shared. The local storage driver writes to the container + filesystem and is not visible to other replicas — use S3 or Azure Blob. + +## Hardening + +Before going live: + +- Set `AUTH_CONSOLE_SIGNING_KEY` and `CSRF_SECRET` from your secret manager, one + value per environment. Never reuse them across environments. +- Replace the `basic` driver with `clerk` so access follows your identity + provider and can be revoked centrally. +- Terminate TLS in front of Lunogram and keep `CSRF_COOKIE_SECURE=true`. +- Set `RATE_LIMIT_TRUSTED_PROXY_HOPS` to the number of proxies you actually run. +- Require TLS on the PostgreSQL connections — the URIs in the examples use + `sslmode=disable` for local development only. +- Keep `courier`, `backoffice`, NATS and Redis on the internal network. +- Remember that media served from `STORAGE_BASE_URL` is protected by an + unguessable URL, not by authorization. See + [Serving media](/storage#serving-media). + +## Upgrades + +By default every node runs migrations at boot (`DATABASE_MIGRATE=true`). With +several replicas rolling at once that means several nodes racing for the same +migration lock — safe, but noisy and slower to roll. + +For a controlled upgrade, run migrations once and start the nodes with +migrations off: + +```bash +DATABASE_MIGRATE=false +``` + +```bash +lunogram -migrate +``` + +The `-migrate` flag applies every pending migration and exits, which makes it a +natural Kubernetes `Job` or init step ahead of the rolling update. + +Nodes shut down gracefully: a leaving node releases its leader lock and cluster +registration, so a rolling restart moves leadership rather than pausing +scheduled work. + +## Backups + +PostgreSQL holds all durable state. Back up all four databases together and +restore them to the same point in time — journey state in `journey` references +users in `subjects` and campaigns in `management`. + +Redis and NATS hold transient state: leader locks, rate-limit counters, caches +and in-flight messages. They do not need backups, but losing NATS mid-flight +drops messages that had been accepted and not yet processed. + +Object storage holds uploaded media and follows its own backup policy. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 8b1ab15a..95a619c8 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -4,6 +4,8 @@ "---Overview---", "introduction", "quick-start", + "---Deployment---", + "enterprise", "---Guides---", "users", "events",