Files
Ekklesia/deploy/compose.yaml
T
ekklesiaandClaude Opus 5.5 e36b70d8d4
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.

- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
  and validate, module validation, module enablement, and Doctor all resolve it
  the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
  from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
  is not installed, and plans and applies Core's and present modules' migrations
  around it. Core's history and every installed module's history stay strict: a
  missing or changed applied migration, or a downgrade, still fails. No down
  migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
  template; .dockerignore excludes modules/ and the build refuses a context that
  contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
  sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
  and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
  modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
  modules, installs a self-contained test module packaged from the template
  (test-only), migrates and restarts, removes it (Core runs, planning reports the
  retained history, data survives), and restores it with its data.

Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 08:38:14 -05:00

166 lines
6.3 KiB
YAML

# 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
# Installed modules (ADR 0048). The image sets the same value; stating it beside the mount below
# keeps the variable and the mount target from ever drifting apart.
EKKLESIA_MODULES_DIR: /var/lib/ekklesia/modules
volumes:
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro
# Installation-owned module storage, outside the image (ADR 0048). Read-only: Ekklesia never
# writes installed code. The host directory must already exist; a missing one stops the
# container from starting instead of Compose quietly creating an empty, root-owned directory.
- type: bind
source: ${EKKLESIA_MODULES_HOST_DIR:-/var/lib/ekklesia/modules}
target: /var/lib/ekklesia/modules
read_only: true
bind:
create_host_path: false
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: