# 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: