Files
Ekklesia/docs/architecture/decisions/0044-self-hosted-reference-deployment.md
ekklesiaandClaude Opus 5.5 7cbe4e1b2c
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m46s
CI / image-build (pull_request) Successful in 25s
CI / deploy-smoke (pull_request) Successful in 41s
chore(release): prepare 0.1.1 and accept ADR 0044
Bump the root and every platform workspace package from 0.1.0 to 0.1.1, so
the platform reports the version the v0.1.1 tag will carry. Module discovery
checks compatibility.platform against the root version and
compatibility.moduleSdk against @ekklesia/module-sdk; both stay inside every
existing ^0.1.0 range, so no module needs a new range. Everything since v0.1.0
is additive (health endpoints, Dashboard serving, production dependency
declarations, the deployment image and Compose project).

templates/module keeps 0.1.0: it is a module, versioned on its own, and its
manifest, source, and tests carry its own product version.

Mark ADR 0044 Accepted. Slices 1-4 are implemented and proven by CI's image
build and deploy smoke; slice 6 (scheduled backups and the restore
procedure) remains its open item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 10:03:33 -05:00

11 KiB
Raw Permalink Blame History

0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources

Context

Phases 1–8 built and verified the platform contracts, but README.md and docs/development/security.md both record that Ekklesia is not deployed: there is no production instance, reverse proxy, public listener, Dashboard asset hosting, or deployment automation. The first real feature module, Signage, now exists in its own repository and needs a production host.

Several gaps only become concrete with a real deployment:

  • Production requires certificate-verified PostgreSQL TLS (verify-full is the only production mode; ADR 0018), including for a database on the same host or Docker network.
  • Browsers require a secure context for the display service worker (ADR 0043), and production identity rejects http and loopback origins. A real, publicly trusted certificate is therefore required even on a private network.
  • Signage declares a storage bucket, so object storage is no longer optional for this deployment, and ADR 0004 left the production provider undecided.
  • A module repository is installed by cloning it into modules/. It depends on platform packages through workspace: ranges, so it can only be built inside an Ekklesia workspace, and the platform's committed pnpm-lock.yaml does not describe it.
  • There is no health endpoint, no backup procedure, and no upgrade procedure.

Decision

Ekklesia provides one reference self-hosted deployment, kept in this repository under deploy/, that any organization can run on a single Linux host with Docker Compose. River of Glory Church is its first instance, but nothing church-specific enters this repository.

Topology

One host, one Compose project, four long-running services on a private network:

Service Role
proxy Caddy. Terminates TLS, serves the only published ports (443; and 80, which answers ACME HTTP-01 challenges and redirects everything else to HTTPS), routes /api/*, /display/*, /assets/* and the Dashboard to api, and a separate storage origin to object-storage.
api The apps/api image: Core, the enabled modules, and the built Dashboard and display host. Listens on the private network only.
postgres PostgreSQL 18 with TLS enabled, using a server certificate issued by an installation-private CA.
object-storage SeaweedFS, the same image family as development, reachable by browsers only through the proxy.

Plus one operator-run, non-restarting service, migrate, described below.

The API serves the Dashboard

The built Dashboard (surfaces/shell, index.html and its bundle) is served by apps/api from the same origin as /api, exactly as the display host already is. This keeps the identity system's default same-origin shape (EKKLESIA_DASHBOARD_ORIGIN unset) and avoids a second static server. The proxy never serves files from disk.

TLS everywhere, without weakening ADR 0018

  • Browser-facing: the proxy obtains publicly trusted certificates through ACME. The reference configuration uses the HTTP-01 challenge on port 80; the DNS-01 challenge is documented for installations that keep Ekklesia off the public Internet. A LAN-only installation can still hold a real certificate for an internal hostname this way. DNS-01 requires a Caddy build that includes a DNS provider module, which the stock Caddy image does not; such an installation builds and pins its own proxy image. A tunnel-only installation needs DNS-01 too: Cloudflare terminates TLS at its edge, so an HTTP-01 challenge cannot reach the proxy through the tunnel.
  • Tunnel ingress (optional): an installation may publish the proxy through an outbound tunnel (the reference documents cloudflared) instead of opening inbound ports. The tunnel connector is a separate Compose service that connects to the proxy over HTTPS and verifies the proxy's certificate. With a token-managed tunnel, those origin settings (the service https://proxy:443, the origin server name, and TLS verification left on) live in the Cloudflare dashboard, not in the repository; the deployment README documents them. The proxy accepts a tunnel-supplied client-address header only from the connector's network identity. It overwrites the single forwarding header it sends to api, and api trusts only the proxy. An installation that serves local displays should give the same hostname a LAN path to the proxy (split-horizon DNS), so displays do not depend on the Internet link.
  • Database: the deployment's setup script creates an installation-private CA once, issues a server certificate for the postgres service name, and gives the API only the CA certificate (EKKLESIA_DATABASE_CA). The API connects with verify-full. No plaintext or encryption-only exception is added to Core.
  • Object storage: production storage must be https (ADR 0030), so the API reaches SeaweedFS over HTTPS on the private network as well. The same setup script issues a server certificate for the object-storage service name from the same CA. Core's storage client trusts Node's default CA set, so the API adds the installation CA to it with NODE_EXTRA_CA_CERTS. No storage TLS exception is added to Core either.

Object storage

SeaweedFS is the reference provider for this deployment. Before the first production instance holds data, the existing storage integration suite (core/tests/module-storage.integration.ts, already run by pnpm test:migrations:integration) must pass against the exact SeaweedFS image the deployment pins. Browsers reach signed object URLs on a dedicated storage origin (S3_DELIVERY_ENDPOINT); the proxy forwards the original Host header unchanged so S3 signatures remain valid (ADR 0031). Any other S3-compatible provider remains a supported substitution.

Composition from pinned sources

A running installation is defined by an instance composition: the platform version and the exact module sources it includes. The image build:

  1. checks out the platform at a release tag;
  2. checks out each listed module at a pinned tag or commit SHA into modules/<id>;
  3. installs with pnpm install --frozen-lockfile against a lockfile committed with the instance composition, not the platform's own lockfile;
  4. runs pnpm verify-equivalent module and migration validation, then builds.

The instance composition (module list, pins, lockfile, non-secret configuration) lives in a separate repository owned by the installation. The reference deployment documents its shape and provides an empty example. Two builds from the same composition produce the same installed code.

This is a deployment mechanism, not a module distribution format. A packaged, installable module artifact (built outside an Ekklesia workspace, against published SDK versions) is a separate future decision.

Migrations stay explicit

The migrate service runs migrations:plan then, only when an operator invokes it, migrations:apply, from the same image as api. The api service never applies migrations. The documented upgrade procedure is: build image → back up → run migrate → restart api.

Health

apps/api gains two unauthenticated endpoints that reveal nothing about installed modules:

  • GET /api/v1/health/live — the process is serving HTTP.
  • GET /api/v1/health/ready — the database is reachable and module startup completed.

Both return a fixed JSON body and Cache-Control: no-store. Compose health checks and external monitoring use them. pnpm doctor remains the detailed operator report.

Backups

The reference deployment includes a scheduled backup job producing a pg_dump custom-format archive and a copy of every bucket under the installation's S3_BUCKET_PREFIX, written to a location outside the host. A restore procedure is documented and must be exercised once before an installation is treated as in production.

Secrets

Secrets are supplied to Compose from root-owned environment files on the host, never committed: one per service, so no container receives another service's credentials. They include EKKLESIA_IDENTITY_SECRET, database and storage credentials, SMTP credentials, and EKKLESIA_MODULE_SECRET__* values. A secret manager is not required.

Consequences

  • Ekklesia has a reproducible, documented way to run in production for the first time, and the ten-item launch checklist in docs/development/security.md becomes checkable against concrete configuration.
  • No Core guarantee is relaxed. The database TLS rule, explicit migrations, and the trusted-code module model are unchanged.
  • The platform and modules begin release tagging, because an instance composition can only pin what has a stable name.
  • An installation that wants Cloudflare Tunnel, a different proxy, or a managed database can substitute it, but the reference configuration is the one CI exercises.
  • Building modules inside a platform workspace remains a known limitation until a module packaging decision replaces the per-instance lockfile.

Alternatives

  • Bare-metal systemd services. Rejected as the reference: harder to reproduce and pin. Remains possible for installations that prefer it.
  • Caddy-internal CA for browsers. Rejected: every display device and admin browser would need the CA installed, which is unrealistic for volunteer-run screens.
  • Relaxing database TLS inside a private Docker network. Rejected: ADR 0018 deliberately has no such exception.
  • Applying migrations automatically at API startup. Rejected: contradicts the explicit-operator rule.
  • Using pnpm install without a frozen lockfile when adding modules. Rejected: the installed dependency graph would differ between builds.
  • Kubernetes. Rejected for the reference: disproportionate for a single church server.

Deferred

  • Module packaging and distribution (published SDK packages, signed module artifacts, an installer).
  • Multi-host, high availability, and managed-database guidance.
  • Container image publishing and automated deployment from CI.
  • Remote-access gates in front of the Dashboard (for example Cloudflare Access), documented as installation choices rather than platform requirements.
  • An authorized offline display descriptor or lease (tracked separately; required before relying on displays to cold-start without the server).