feat(deploy): add the reference deployment Compose project
CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 27s
CI / deploy-smoke (pull_request) Successful in 42s

Implement ADR 0044's reference deployment as a base Compose file plus
overlays, so an installation that already runs a proxy can use the base
alone.

- compose.yaml: api, postgres, object-storage, and the operator-only
  migrate (profile ops, plan by default). No published port. api and
  migrate run init, read-only, tmpfs /tmp, cap_drop ALL,
  no-new-privileges. postgres is only on the internal data network.
- TLS everywhere from one installation-private CA: PostgreSQL TLS 1.3
  only, with pg_hba rejecting every non-TLS connection, and the API on
  verify-full via EKKLESIA_DATABASE_CA (PEM text). SeaweedFS serves S3
  over HTTPS (ADR 0030 requires https in production), trusted by the
  API through NODE_EXTRA_CA_CERTS. No Core change.
- compose.proxy.yaml + Caddyfile.example: Caddy pinned by digest, ACME
  HTTP-01 (DNS-01 documented), routes /, /api/*, /display/*, /assets/*
  to api and refuses everything else; the storage hostname preserves
  Host. The API trusts exactly the proxy's fixed /32 on an edge network
  whose dynamic range excludes the fixed addresses; Caddy overwrites
  X-Forwarded-For and strips caller-supplied forwarding headers.
- compose.tunnel.yaml: cloudflared pinned by digest, token from its own
  env file; Caddy honors CF-Connecting-IP only from its fixed address.
- Four per-service root-owned env files, examples with no values;
  scripts/init-tls.sh (idempotent, never overwrites a key) and
  scripts/gen-secrets.sh (prints only).
- CI: pnpm deploy:config in ci:verify for all three combinations, and a
  deploy-smoke job (scripts/smoke-deploy.sh) proving readiness, the
  Dashboard, signed storage delivery, the client-address chain, initial
  owner sign-in/out, verify-full database TLS, and Doctor through the
  proxy, against disposable services. tests/deploy.test.ts pins the
  structural guarantees.
- ADR 0044 (still Proposed) amended to match: API-to-storage HTTPS,
  DNS-01 needs a custom Caddy build and tunnel-only needs DNS-01, the
  tunnel's origin TLS settings live in the Cloudflare dashboard, port
  80 serves HTTP-01, and secrets are one env file per service.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-01 20:59:38 -05:00
co-authored by Claude Opus 5.5
parent b593abe7a5
commit 167b4c4f14
21 changed files with 1902 additions and 116 deletions
@@ -48,7 +48,7 @@ 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, 80 for redirect), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. |
| `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. |
@@ -65,21 +65,31 @@ 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; 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.
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. 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.
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
@@ -134,8 +144,9 @@ an installation is treated as in production.
### Secrets
Secrets are supplied to Compose from a root-owned environment file on the host, never committed.
This includes `EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
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
+9 -7
View File
@@ -463,11 +463,12 @@ Until that exists, the trusted-code model above is the security model.
## Current deployment state
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse
proxy, no public listener, and no deployment automation in this repository — no deploy script, no
CI deployment step, no container publish, no orchestration manifest. The repository can **build** a
production image ([`deploy/`](../../deploy/README.md)), and CI builds it without modules on every
change, but nothing pushes, publishes, or runs it anywhere. No `.env` has ever been committed; only
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no running
reverse proxy, no public listener, and no deployment automation in this repository — no deploy
script, no CI deployment step, no container publish. The repository provides a **reference deployment**
([`deploy/`](../../deploy/README.md)): a production image and the Compose project that runs it, which
CI builds and smoke-tests end to end on every change against disposable services. Nothing pushes,
publishes, or runs it against a real installation. No `.env` has ever been committed; only
`.env.example`, which the image build context excludes.
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
@@ -490,8 +491,9 @@ outstanding.
1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)); the repository
does not yet supply the proxy deployment.
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
maps every item here to its configuration).
2. The origin port is unreachable from the public Internet.
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.