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
+153
View File
@@ -0,0 +1,153 @@
# Ekklesia's reference self-hosted deployment: the base project (ADR 0044).
#
# `api`, `postgres`, and `object-storage`, plus the operator-only `migrate` service. Nothing here
# publishes a port. Add `compose.proxy.yaml` for the reference TLS proxy, and `compose.tunnel.yaml`
# on top of that for a Cloudflare Tunnel connector; or attach `api` and `object-storage` to an
# existing proxy's network yourself (deploy/README.md).
#
# Non-secret settings come from `deploy/.env` (copy `env/compose.env.example`). Secrets live in
# root-owned, mode-600 files under EKKLESIA_ENV_DIR, one per service, so no container receives
# another service's credentials. TLS material comes from `scripts/init-tls.sh`.
name: ekklesia
x-ekklesia-hardening: &ekklesia-hardening
init: true
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
x-ekklesia-app: &ekklesia-app
<<: *ekklesia-hardening
image: ${EKKLESIA_IMAGE:?EKKLESIA_IMAGE is required, for example registry.example.org/ekklesia:0.1.0}
env_file:
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/ekklesia.env
environment:
# Core's object-storage client trusts Node's default CA set. Adding the installation-private CA
# to it, from the file below, is what lets the API verify `object-storage`'s certificate
# without any storage TLS exception. The database CA travels separately, as PEM text in
# EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts.
NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt
volumes:
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro
networks:
data: {}
storage: {}
egress: {}
services:
api:
<<: *ekklesia-app
restart: unless-stopped
# The image's own HEALTHCHECK (GET /api/v1/health/ready) applies unchanged.
depends_on:
postgres:
condition: service_healthy
object-storage:
condition: service_healthy
# Operator commands only. `profiles` keeps `docker compose up` from ever starting it; it runs only
# through `docker compose run --rm migrate …`. The default command is the read-only plan.
migrate:
<<: *ekklesia-app
profiles:
- ops
restart: "no"
healthcheck:
disable: true
command: ["node", "core/dist/cli/plan-migrations.js"]
depends_on:
postgres:
condition: service_healthy
object-storage:
condition: service_healthy
postgres:
image: postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file:
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/postgres.env
environment:
POSTGRES_INITDB_ARGS: --data-checksums --auth-host=scram-sha-256 --auth-local=trust
TZ: UTC
# TLS with the installation-private CA's server certificate for the name `postgres`, TLS 1.3
# only, and a host-based authentication file that rejects every non-TLS network connection.
# The API connects with verify-full; there is no plaintext path to fall back to.
command:
- postgres
- -c
- ssl=on
- -c
- ssl_cert_file=/run/ekklesia/tls/postgres.crt
- -c
- ssl_key_file=/run/ekklesia/tls/postgres.key
- -c
- ssl_min_protocol_version=TLSv1.3
- -c
- hba_file=/etc/ekklesia/pg_hba.conf
volumes:
- postgres-data:/var/lib/postgresql
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.crt:/run/ekklesia/tls/postgres.crt:ro
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.key:/run/ekklesia/tls/postgres.key:ro
- ./postgres/pg_hba.conf:/etc/ekklesia/pg_hba.conf:ro
# Only `data`: never a proxy-facing network, and no route out of the host.
networks:
data: {}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 5s
timeout: 5s
retries: 12
start_period: 20s
object-storage:
image: chrislusf/seaweedfs:4.28@sha256:d071979843142451d41566bc09f6adaa36902c112683d900a417247c82e9c3c1
restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file:
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/object-storage.env
environment:
TZ: UTC
# The S3 API over HTTPS on 8443, with the installation-private CA's certificate for the name
# `object-storage`: production storage must be https (ADR 0030), for the API and for the proxy.
command:
- mini
- -dir=/data
- -s3.port.https=8443
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
- -s3.key.file=/run/ekklesia/tls/object-storage.key
volumes:
- object-storage-data:/data
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.crt:/run/ekklesia/tls/object-storage.crt:ro
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.key:/run/ekklesia/tls/object-storage.key:ro
networks:
storage: {}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"]
interval: 5s
timeout: 5s
retries: 18
start_period: 15s
networks:
# The database network: postgres, api, and migrate. Internal, so nothing on it reaches beyond the
# host and no proxy is ever attached to it.
data:
internal: true
# The storage network: object-storage, api, and migrate. Internal for the same reason; browsers
# reach storage only through a proxy that joins it on `edge`.
storage:
internal: true
# Outbound access for api and migrate (an SMTP relay, for example). Nothing listens on it.
egress: {}
volumes:
postgres-data:
object-storage-data: