feat(deploy): add the reference deployment Compose project #49

Merged
Zayin_Admin merged 1 commits from feat/deploy-reference-compose into main 2026-10-02 03:08:08 +00:00
21 changed files with 1902 additions and 116 deletions
Showing only changes of commit 167b4c4f14 - Show all commits
+23
View File
@@ -135,3 +135,26 @@ jobs:
- name: Remove the image from the runner
if: always()
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA" || true
deploy-smoke:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 20
env:
DOCKER_BUILDKIT: "1"
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
# database TLS through the proxy. Every secret is generated for this run and discarded with
# it; nothing is pushed, published, or logged in to.
- name: Smoke-test the reference deployment
run: bash scripts/smoke-deploy.sh
+87
View File
@@ -0,0 +1,87 @@
# Ekklesia reference proxy configuration (ADR 0044). Copy to deploy/Caddyfile, which the proxy
# overlay mounts read-only. Hostnames, the ACME contact, and the tunnel connector's address come from
# the proxy service's environment (deploy/.env), so this file normally needs no edits.
{
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
# the Ekklesia and storage hostnames to resolve publicly to this host with 80 and 443 reachable.
email {$EKKLESIA_ACME_EMAIL}
# DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN
# (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at
# its edge, so HTTP-01 cannot reach this proxy through a tunnel.
#
# DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image
# with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by
# digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then
# enable:
#
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
servers {
# A client-address header is honored only from the tunnel connector's fixed address
# (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and
# any such header is ignored. Without a tunnel, nothing has this address.
trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32
client_ip_headers CF-Connecting-IP
trusted_proxies_strict
}
# Access logs are off unless enabled. If you enable them, keep request headers out: they carry
# cookies and forwarding chains (docs/development/security.md, launch checklist item 7).
}
# What every request to the API carries upstream: exactly one forwarding header, overwritten with
# the client address Caddy resolved, and none of the headers a caller could use to choose its own.
(ekklesia_forwarding) {
header_up X-Forwarded-For {client_ip}
header_up -CF-Connecting-IP
header_up -X-Real-IP
header_up -Forwarded
header_up -True-Client-IP
}
{$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at /, the API, display pages and their service
# worker, and the Dashboard and display host scripts. Any other path is refused here.
@ekklesia path / /api/* /display/* /assets/*
# Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/*
handle @realtime {
reverse_proxy api:3000 {
import ekklesia_forwarding
flush_interval -1
}
}
handle @ekklesia {
# Request bodies stream through unbuffered and unlimited here; the API enforces the platform's
# ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route).
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
}
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
{$EKKLESIA_STORAGE_HOSTNAME} {
reverse_proxy https://object-storage:8443 {
# An S3 signature covers the Host header: pass the original through unchanged, or every signed
# URL fails verification.
header_up Host {host}
header_up -CF-Connecting-IP
header_up -X-Real-IP
header_up -Forwarded
header_up -True-Client-IP
transport http {
# object-storage presents a certificate from the installation-private CA.
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
tls_server_name object-storage
}
}
}
+302 -97
View File
@@ -1,9 +1,273 @@
# Deployment
This directory holds Ekklesia's reference self-hosted deployment
([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)). Today it
contains the production container image. The Compose project, proxy configuration, and backup job
the ADR describes are separate, later work.
([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)): one Linux
host, one Docker Compose project, and the production container image it runs. Nothing here is
specific to one organization.
| File | What it is |
| --- | --- |
| [`compose.yaml`](compose.yaml) | The base: `api`, `postgres`, `object-storage`, and the operator-only `migrate`. Publishes no port. |
| [`compose.proxy.yaml`](compose.proxy.yaml) | Overlay: Caddy as the TLS proxy, the only service with published ports. |
| [`compose.tunnel.yaml`](compose.tunnel.yaml) | Overlay on top of the proxy: a Cloudflare Tunnel connector. |
| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration; copy to `Caddyfile`. |
| [`postgres/pg_hba.conf`](postgres/pg_hba.conf) | PostgreSQL client authentication: TLS only, SCRAM passwords. |
| [`env/`](env/) | Commented examples of every env file. No values. |
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
## The reference deployment
### Shape
```sh
cd deploy
# Base and proxy: the reference.
docker compose -f compose.yaml -f compose.proxy.yaml up -d
# Base, proxy, and Cloudflare Tunnel.
docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
# Base only, behind a reverse proxy the installation already runs.
docker compose -f compose.yaml -f compose.myproxy.yaml up -d
```
Pass the same `-f` files to every later `up`, `down`, `ps`, and `logs`. `migrate` commands need only
the base: `docker compose run --rm migrate …` from this directory uses `compose.yaml` by default.
| Network | Kind | Members |
| --- | --- | --- |
| `data` | internal | `postgres`, `api`, `migrate` |
| `storage` | internal | `object-storage`, `api`, `migrate` |
| `egress` | bridge | `api`, `migrate` (outbound only, for example an SMTP relay) |
| `edge` | bridge, fixed subnet (proxy overlay) | `proxy` (fixed address), `api`, `object-storage`, `tunnel` (fixed address) |
PostgreSQL is only ever on `data`, so no proxy and no tunnel can reach it. `internal` networks have
no route off the host. Object storage joins `edge` only when the proxy overlay attaches it.
TLS is everywhere, without any exception in Core:
- **Browsers to the proxy:** publicly trusted ACME certificates.
- **API to PostgreSQL:** `verify-full` against the installation-private CA, passed as PEM text in
`EKKLESIA_DATABASE_CA`. PostgreSQL accepts only TLS 1.3 network connections.
- **API (and proxy) to object storage:** HTTPS on port 8443 with a certificate from the same CA.
Production storage must be https (ADR 0030). Core's S3 client uses Node's default trust store, so
Compose adds the CA with `NODE_EXTRA_CA_CERTS`.
### Where configuration and secrets live
| File (on the host) | Holds | Read by |
| --- | --- | --- |
| `deploy/.env` (from [`env/compose.env.example`](env/compose.env.example)) | Non-secret settings: the image, hostnames, paths, the edge subnet | Compose itself |
| `/etc/ekklesia/ekklesia.env` | Everything the API needs, including `DATABASE_URL` and the S3 keys | `api`, `migrate` |
| `/etc/ekklesia/postgres.env` | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | `postgres` |
| `/etc/ekklesia/object-storage.env` | The S3 key pair | `object-storage` |
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file |
The secret files are owned by root with mode 600, one per service, so no container receives another
service's credentials. None is committed, and `deploy/.env` is ignored by Git. `EKKLESIA_ENV_DIR`
and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories.
### First boot
1. **Get the image.** Build it ([Building](#building)) or pull it, and set `EKKLESIA_IMAGE` in
`deploy/.env`.
2. **Create the TLS material**, as root:
```sh
sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
```
It creates the CA once and a certificate for each of `postgres` and `object-storage`, gives each
key to its server's uid (PostgreSQL 70, SeaweedFS 1000) with mode 600, and prints the exact
`EKKLESIA_DATABASE_CA="…"` line. It never overwrites an existing key or certificate and never
prints a private key. `ca.key` is needed only to issue certificates: keep a copy offline.
3. **Generate secrets** with `sh deploy/scripts/gen-secrets.sh`, and paste each line into the file
named above it.
4. **Write the env files** from [`env/`](env/), then install each one owned by root with mode 600:
```sh
sudo install -d -m 755 /etc/ekklesia
sudo install -o root -g root -m 600 ekklesia.env /etc/ekklesia/ekklesia.env
```
`EKKLESIA_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your
Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname.
5. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`,
`EKKLESIA_STORAGE_HOSTNAME`, and `EKKLESIA_ACME_EMAIL` in `deploy/.env`. Both hostnames must
resolve to this host with ports 80 and 443 reachable for HTTP-01, or see [DNS-01](#dns-01).
6. **Start the data services, then migrate explicitly:**
```sh
docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage
docker compose run --rm migrate # plan: read-only
docker compose run --rm migrate node core/dist/cli/apply-migrations.js
```
Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when
you run it.
7. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
8. **Check it:** `curl https://<hostname>/api/v1/health/ready` answers `{"status":"ready"}`, and
`docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings
for anything optional you left unconfigured, such as email).
### The initial owner
```sh
docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email [email protected]
```
It prompts for the password with hidden input and never accepts it on the command line. The owner
is created already verified, so they can sign in before any email relay exists
([ADR 0035](../docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)). To supply the
password from a secret manager instead, pipe it on standard input and use `-T` instead of `-it`.
### Upgrading
1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag.
2. **Back up** ([Backups](#backups)).
3. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
4. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
the upgrade applied no migrations (the plan in step 3 showed none pending). Migrations are
forward-only. If any were applied, restore the backup taken in step 2 before starting the previous
image, because it was not built for the newer schema.
### Backups
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then,
take a `pg_dump` and a copy of every bucket before each upgrade by hand.
### Behind an existing reverse proxy
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
your own, for example `compose.myproxy.yaml`:
```yaml
services:
api:
networks:
proxy: {}
environment:
# Exactly one trusted peer: your proxy, at an address that cannot change.
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: 10.0.100.254/32
object-storage:
networks:
proxy: {}
networks:
proxy:
external: true
name: your-proxy-network
```
Then make your proxy do what `Caddyfile.example` does:
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
- set no request-body limit below 32 MiB; the API enforces its own ceilings;
- forward the storage hostname to `https://object-storage:8443`, verifying it against
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
through unchanged**, or every signed URL fails (ADR 0031).
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
range, as `compose.proxy.yaml` does.
### Fixed addresses on `edge`
`compose.proxy.yaml` gives `edge` the subnet `10.249.250.0/24` and lets Docker assign dynamic
addresses only from its lower half (`10.249.250.0/25`). The proxy (`.254`) and the tunnel connector
(`.253`) take fixed addresses from the top of the upper half, which Docker never assigns to anything
else. That is what makes "only the proxy" and "only the connector" expressible as `/32` trust
entries. If the subnet collides with one on your host, change `EKKLESIA_EDGE_SUBNET`,
`EKKLESIA_EDGE_DYNAMIC_RANGE`, `EKKLESIA_PROXY_ADDRESS`, and `EKKLESIA_TUNNEL_ADDRESS` together,
keeping the fixed addresses at the top and outside the dynamic range.
### Cloudflare Tunnel
`compose.tunnel.yaml` runs `cloudflared` with a token, so the tunnel is managed in the Cloudflare
dashboard. No Cloudflare API is called from here.
1. In Cloudflare Zero Trust, under **Networks → Tunnels**, create a Cloudflared tunnel. Copy its
token into `/etc/ekklesia/tunnel.env` as `TUNNEL_TOKEN`.
2. Add a **public hostname** (shown as a *published application route* in newer dashboards) for
the Ekklesia hostname. Service type **HTTPS**, URL `proxy:443`.
3. Under that hostname's **TLS** settings, set **Origin Server Name** to the same Ekklesia hostname,
and leave **No TLS Verify** off. The connector then verifies the proxy's certificate for that name.
4. Add a second public hostname for the storage hostname, the same way, with its own origin server
name.
5. Start with all three files. The proxy honors `CF-Connecting-IP` only from the connector's fixed
address. From any other peer it is stripped.
The proxy's certificate must still be publicly trusted, and an HTTP-01 challenge cannot reach the
proxy through a tunnel, because Cloudflare terminates TLS at its edge. A tunnel-only installation
therefore uses [DNS-01](#dns-01). An installation that also serves local displays should give the
same hostnames a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
Internet link.
### DNS-01
For an installation off the public Internet, LAN-only, or tunnel-only. DNS-01 needs a DNS provider
module, which the stock Caddy image does not contain:
1. Build a Caddy image with the module (for example
`xcaddy build --with github.com/caddy-dns/cloudflare`) and pin it by digest.
2. Override the proxy's `image:` in an overlay of your own. Give it `CLOUDFLARE_API_TOKEN` (a token
scoped to DNS edits for the zone) through a root-owned env file.
3. Enable the commented `acme_dns` line in your `Caddyfile`.
### Launch checklist
The ten items in [Security § launch checklist](../docs/development/security.md) mapped to this
deployment:
| # | Item | Where it is met |
| --- | --- | --- |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. |
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
| 5 | Rotating forwarding headers cannot rotate the rate-limit bucket | The same, plus stripping `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; the API overwrites its internal header. Proven by the smoke test. |
| 6 | The proxy peer is the only trusted proxy | `compose.proxy.yaml` sets `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` to the proxy's fixed `/32`. |
| 7 | Proxy logs record no cookies, credentials, bodies, or forwarding chains | Caddy access logs are off in `Caddyfile.example`. If you enable them, exclude request headers. |
| 8 | PostgreSQL only from the API, over verified TLS | `postgres` is only on internal `data`; `pg_hba.conf` rejects non-TLS; `verify-full` with `EKKLESIA_DATABASE_CA`. Proven by the smoke test. |
| 9 | The deployed login and logout flow keeps its contract | Unchanged by the proxy; the smoke test signs in and out through it. |
| 10 | A supported way to create the first production identity | `create-initial-owner.js` through `migrate` ([The initial owner](#the-initial-owner)). Exercised by the smoke test. |
### Testing this deployment
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
env files. CI runs it in `pnpm ci:verify`.
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against a
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
follows the first boot above and checks the following through the proxy:
- readiness and the Dashboard;
- a signed object URL fetched through the storage hostname;
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
- initial-owner sign-in and sign-out;
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
wrong host name;
- Doctor.
CI runs it on every change.
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
has no public hostname. Never use it in an installation; `Caddyfile.example` does not contain it.
The smoke test uses no module, so it does not exercise realtime streams or large uploads. Through
this same proxy configuration, a scratch module confirmed both:
- a Server-Sent Event crossed the proxy in milliseconds;
- a 32 MiB streamed upload passed with and without `Content-Length`;
- one byte more was refused by the API's own `413 REQUEST_BODY_TOO_LARGE`.
## The production image
@@ -79,8 +343,7 @@ example, process one image).
### Configuration
Configuration comes **only from the container environment**, never from a file inside the image.
Supply it from a root-owned environment file on the host, outside any repository (ADR 0044
Secrets). The image sets exactly three variables itself:
The image sets exactly three variables itself:
| Variable | Image value | Why |
| --- | --- | --- |
@@ -89,48 +352,26 @@ Secrets). The image sets exactly three variables itself:
| `EKKLESIA_API_PORT` | `3000` | The platform default, made explicit; `EXPOSE 3000`. |
Everything else is the ordinary host configuration in
[API § Required configuration](../docs/development/api.md#required-configuration). In production
that means `DATABASE_URL` with certificate-verified TLS: `EKKLESIA_DATABASE_CA` is the CA's PEM text
itself, passed in the environment, so no certificate file is mounted.
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
reference deployment's `ekklesia.env`. `EKKLESIA_DATABASE_CA` is the CA's PEM text itself, on one
double-quoted line with `\n` escapes. Compose expands those escapes; plain `docker run --env-file`
does not, which is why every command here goes through Compose.
Never publish the API's port to the host or the Internet. Only the proxy publishes ports.
### The `api` service
### Process, signals, and the read-only posture
The image's default command is `node apps/api/dist/server.js`: `pnpm api:start` without its build
step and without `--env-file`. It never applies migrations.
`compose.yaml` runs `api` and `migrate` with `init: true`, `read_only: true`, `tmpfs: /tmp`,
`cap_drop: [ALL]`, and `no-new-privileges`. That is the supported production posture:
```yaml
services:
api:
image: ekklesia:0.1.0
init: true
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file:
- /etc/ekklesia/ekklesia.env
restart: unless-stopped
```
**Use `init: true`.** It runs a minimal init as PID 1 that forwards signals and reaps children, so
`docker stop` reaches the API's own SIGTERM handler promptly. The API then drains in-flight
requests, stops modules in reverse order, and closes its pool.
**The read-only flags are the supported production posture.** The image was verified under exactly
`--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges`, with `--init`:
- the API reached `healthy` and served `/api/v1/health/ready`, `GET /`, and the Dashboard script;
- `plan-migrations.js`, `apply-migrations.js`, and `doctor.js` ran under the same flags;
- nothing wrote to `/tmp` in that run. It stays mounted as headroom for module code that needs
scratch space.
The installation, `/home/node` included, is not writable, so anything that needs a writable path
other than `/tmp` should be reported as a defect rather than worked around.
- **`init: true`** runs a minimal init as PID 1 that forwards signals and reaps children, so
`docker stop` reaches the API's own SIGTERM handler promptly. The API then drains in-flight
requests, stops modules in reverse order, and closes its pool.
- **Read-only:** the image was verified under exactly these flags. The API reached `healthy`, and
the migration and Doctor commands ran. Nothing wrote to `/tmp`, which stays mounted as headroom
for module code that needs scratch space. The installation, `/home/node` included, is not
writable, so anything that needs a writable path other than `/tmp` is a defect to report, not to
work around.
### Health
@@ -138,68 +379,32 @@ The image's `HEALTHCHECK` calls `GET /api/v1/health/ready` on `127.0.0.1` with N
`fetch`, every 30 seconds after a 30-second start period, with three retries. Readiness means the
database answered within 2 seconds and the instance is not shutting down; see
[API § Health](../docs/development/api.md#health). Because it probes `127.0.0.1`, keep
`EKKLESIA_API_HOST` at `0.0.0.0` (or any address that includes loopback) inside the container.
`EKKLESIA_API_HOST` at `0.0.0.0` (or any address that includes loopback) inside the container. The
API trusts only the proxy's address for client-address headers, so the loopback probe is an
ordinary direct peer.
A Compose health check or external monitor can use the same endpoint through the proxy. Use
`/api/v1/health/live` to ask only whether the process is serving.
An external monitor can use the same endpoint through the proxy. Use `/api/v1/health/live` to ask
only whether the process is serving.
### Operator commands
The same image runs every CLI, directly with `node core/dist/cli/<command>.js`. The `pnpm` scripts
of the same names rebuild first and read `.env`, so they are for a development checkout, not for the
image.
The same image runs every CLI, directly with `node core/dist/cli/<command>.js`, through the
`migrate` service. The `pnpm` scripts of the same names rebuild first and read `.env`, so they are
for a development checkout, not for the image.
**Nothing applies migrations automatically.** Neither the API nor the image's default command ever
runs `apply-migrations.js`; only an operator does, by running the command below.
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
and its default command is the read-only plan. **Nothing applies migrations automatically.**
A `migrate` service makes those commands one line each. It shares the API's image and environment,
never restarts, and does not run the API's health check:
| Task | Command (from `deploy/`) |
| --- | --- |
| Plan migrations (read-only) | `docker compose run --rm migrate` |
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` |
| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` |
| Create the initial owner (interactive) | `docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email owner@example.org` |
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` (or `--disable`) |
```yaml
services:
migrate:
image: ekklesia:0.1.0
profiles: ["operator"]
restart: "no"
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file:
- /etc/ekklesia/ekklesia.env
healthcheck:
disable: true
command: ["node", "core/dist/cli/plan-migrations.js"]
```
| Task | Compose | Plain `docker run` |
| --- | --- | --- |
| Plan migrations (read-only) | `docker compose run --rm migrate` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/plan-migrations.js` |
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/apply-migrations.js` |
| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/doctor.js` |
| Create the initial owner (interactive) | `docker compose run --rm migrate node core/dist/cli/create-initial-owner.js --email [email protected]` | `docker run --rm -it --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/create-initial-owner.js --email [email protected]` |
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/set-module-enabled.js --disable --module <id>` |
Notes:
- `docker compose run` allocates a terminal by default. With plain `docker run`, the initial-owner
command needs `-it`: it prompts for the password with hidden input and never accepts it on the
command line ([Identity](../docs/development/identity.md)). It also accepts the password on
standard input with `-i` and no `-t`.
- Run `--rm` commands on the same Docker network as `postgres`, so `DATABASE_URL` resolves; with
Compose that is automatic.
- Add the same read-only flags to plain `docker run` commands if you want them; every command above
was verified under them except the interactive and module-enablement ones, which were not run.
### Upgrading
The ADR's procedure, with this image: build the new image → back up → run
`docker compose run --rm migrate` (plan), review it, then
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js` → restart `api` on the
new image. Migrations always run before the new API starts, and only when an operator runs them.
The plan, apply, Doctor, and initial-owner commands are exercised by the smoke test; module
enablement is not.
### Size
+80
View File
@@ -0,0 +1,80 @@
# The reference TLS proxy overlay (ADR 0044's `proxy` service). Use it on top of the base:
#
# docker compose -f compose.yaml -f compose.proxy.yaml up -d
#
# Caddy terminates TLS with publicly trusted ACME certificates and publishes the deployment's only
# ports. It reaches `api` and `object-storage` on the `edge` network, which this overlay creates and
# attaches them to; `postgres` is never on it.
#
# Client addresses. Caddy is the only peer the API trusts to say who the client is: this overlay
# puts the API in trusted-proxy mode for exactly the proxy's fixed address, and the Caddyfile
# overwrites X-Forwarded-For with the one address Caddy resolved. Because the address must be
# fixed for "only the proxy" to be expressible, `edge` has an explicit subnet whose dynamic range is
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
# which Docker never hands to another container.
services:
proxy:
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
ports:
# 80 answers ACME HTTP-01 challenges and redirects everything else to HTTPS. Set
# EKKLESIA_PUBLISH_ADDRESS to bind one host interface instead of all of them.
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTP_PORT:-80}:80"
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443"
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443/udp"
environment:
EKKLESIA_HOSTNAME: ${EKKLESIA_HOSTNAME:?EKKLESIA_HOSTNAME is required, for example ekklesia.example.org}
EKKLESIA_STORAGE_HOSTNAME: ${EKKLESIA_STORAGE_HOSTNAME:?EKKLESIA_STORAGE_HOSTNAME is required, for example storage.ekklesia.example.org}
EKKLESIA_ACME_EMAIL: ${EKKLESIA_ACME_EMAIL:?EKKLESIA_ACME_EMAIL is required for ACME account notices}
EKKLESIA_TUNNEL_ADDRESS: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
volumes:
- ${EKKLESIA_CADDYFILE:-./Caddyfile}:/etc/caddy/Caddyfile:ro
# The installation-private CA, so Caddy can verify object-storage's certificate.
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/etc/caddy/ekklesia-ca.crt:ro
- proxy-data:/data
- proxy-config:/config
networks:
edge:
ipv4_address: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}
depends_on:
api:
condition: service_healthy
object-storage:
condition: service_healthy
api:
networks:
edge: {}
environment:
# Exactly one trusted peer: this proxy. Set here from the same variable that fixes the proxy's
# address, so the two cannot drift apart. These override any value in ekklesia.env.
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
object-storage:
networks:
edge: {}
networks:
edge:
ipam:
config:
- subnet: ${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}
# Docker assigns dynamic addresses only from this range. Keep the fixed proxy and tunnel
# addresses outside it.
ip_range: ${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}
volumes:
proxy-data:
proxy-config:
+30
View File
@@ -0,0 +1,30 @@
# The Cloudflare Tunnel overlay (ADR 0044, optional ingress). Use it on top of base and proxy:
#
# docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
#
# `cloudflared` makes an outbound connection to Cloudflare and forwards visitors to the proxy over
# HTTPS. The tunnel is token-managed, so its public hostnames and origin settings live in the
# Cloudflare dashboard: each hostname's service is `https://proxy:443` with the matching origin
# server name and TLS verification left on (deploy/README.md, "Cloudflare Tunnel").
#
# The connector has a fixed address on `edge`. The Caddyfile trusts a client-address header
# (CF-Connecting-IP) only from that address, so no other peer can choose its own client address.
services:
tunnel:
image: cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# The image's entrypoint is `cloudflared --no-autoupdate`; the token comes from TUNNEL_TOKEN.
command: ["tunnel", "run"]
env_file:
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env
networks:
edge:
ipv4_address: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
depends_on:
- proxy
+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:
+29
View File
@@ -0,0 +1,29 @@
# Non-secret settings for the reference deployment's Compose project. Copy to deploy/.env, which
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
# per-service files under EKKLESIA_ENV_DIR.
# The image to run, by registry, name, and tag (or digest). Required.
EKKLESIA_IMAGE=
# Where the root-owned, mode-600 env files and the TLS directory live on the host.
EKKLESIA_ENV_DIR=/etc/ekklesia
EKKLESIA_TLS_DIR=/etc/ekklesia/tls
# --- compose.proxy.yaml ---------------------------------------------------------------------------
# The Ekklesia hostname (Dashboard, API, displays) and the storage hostname (signed object URLs).
EKKLESIA_HOSTNAME=
EKKLESIA_STORAGE_HOSTNAME=
# Contact address for the ACME account (certificate expiry notices).
EKKLESIA_ACME_EMAIL=
# EKKLESIA_CADDYFILE=./Caddyfile
# EKKLESIA_PUBLISH_ADDRESS=0.0.0.0
# EKKLESIA_HTTP_PORT=80
# EKKLESIA_HTTPS_PORT=443
# The edge network. Change the subnet only if it collides with one already on the host. Docker
# assigns dynamic addresses only from EKKLESIA_EDGE_DYNAMIC_RANGE (the lower half); the proxy and
# the tunnel connector take fixed addresses from the upper half, so nothing else can ever hold them.
# EKKLESIA_EDGE_SUBNET=10.249.250.0/24
# EKKLESIA_EDGE_DYNAMIC_RANGE=10.249.250.0/25
# EKKLESIA_PROXY_ADDRESS=10.249.250.254
# EKKLESIA_TUNNEL_ADDRESS=10.249.250.253
+71
View File
@@ -0,0 +1,71 @@
# Ekklesia API and operator commands (`api` and `migrate`). Reference deployment, ADR 0044.
#
# Install as EKKLESIA_ENV_DIR/ekklesia.env (default /etc/ekklesia/ekklesia.env), owned by root with
# mode 600:
# install -o root -g root -m 600 env/ekklesia.env.example /etc/ekklesia/ekklesia.env
# Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA
# line with scripts/init-tls.sh.
#
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, and EKKLESIA_API_PORT=3000.
# Compose sets NODE_EXTRA_CA_CERTS. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_*
# variables. None of those belong here.
#
# Values that are part of the reference topology are filled in; everything installation-specific is
# blank. Full reference: docs/development/api.md#required-configuration.
# --- Database (ADR 0018) ------------------------------------------------------------------------
# The host must be exactly `postgres`: the server certificate is issued for that name and
# verify-full checks it. User, password, and database must match postgres.env. Percent-encode any
# reserved character in the password. Never add sslmode or another TLS parameter to this URL.
DATABASE_URL=postgresql://<user>:<password>@postgres:5432/<database>
EKKLESIA_DATABASE_TLS_MODE=verify-full
# The installation-private CA as PEM text (not a path), on one double-quoted line with \n escapes.
# scripts/init-tls.sh prints this exact line. It is the only CA the database connection trusts.
EKKLESIA_DATABASE_CA=
# --- Identity (ADR 0011, ADR 0018, ADR 0034) ----------------------------------------------------
# The public https origin of the Ekklesia hostname, with no path. Production rejects http and
# loopback origins.
EKKLESIA_IDENTITY_BASE_URL=
# Comma-separated exact origins allowed to make credentialed requests. Normally just the base URL.
EKKLESIA_IDENTITY_TRUSTED_ORIGINS=
# At least 32 random characters. Rotating it signs every user out.
EKKLESIA_IDENTITY_SECRET=
# Leave unset: the Dashboard is served from the base URL (ADR 0044).
# EKKLESIA_DASHBOARD_ORIGIN=
# Production refuses public sign-up. Leave unset.
# EKKLESIA_PUBLIC_SIGNUP_ENABLED=
# --- Client address (ADR 0017) ------------------------------------------------------------------
# Set by compose.proxy.yaml. Only a base-only deployment behind its own proxy sets these here, with
# that proxy's fixed address as the only trusted entry (deploy/README.md):
# EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy
# EKKLESIA_CLIENT_ADDRESS_HEADER=x-forwarded-for
# EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES=<proxy address>/32
# --- Object storage (ADR 0030, ADR 0031) --------------------------------------------------------
# The API reaches SeaweedFS over HTTPS on the private network; production storage must be https.
S3_ENDPOINT=https://object-storage:8443
# The https origin browsers fetch signed object URLs from: the storage hostname on the proxy.
S3_DELIVERY_ENDPOINT=
# Must match object-storage.env.
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true
S3_BUCKET_PREFIX=ekklesia
# --- Outbound email (ADR 0037), optional --------------------------------------------------------
# Without a relay, email verification and password recovery cannot deliver. See
# docs/development/email.md.
# EKKLESIA_SMTP_HOST=
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# EKKLESIA_SMTP_USERNAME=
# EKKLESIA_SMTP_PASSWORD=
# EKKLESIA_SMTP_FROM_ADDRESS=
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
# EKKLESIA_SMTP_HELO_NAME=
# --- Module secrets (ADR 0029), as installed modules declare them --------------------------------
# EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=
+4
View File
@@ -0,0 +1,4 @@
# SeaweedFS S3 credentials (`object-storage` only). Install as EKKLESIA_ENV_DIR/object-storage.env,
# owned by root with mode 600. Must match S3_ACCESS_KEY and S3_SECRET_KEY in ekklesia.env.
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
+8
View File
@@ -0,0 +1,8 @@
# PostgreSQL superuser for the reference deployment (`postgres` only). Install as
# EKKLESIA_ENV_DIR/postgres.env, owned by root with mode 600. Read only on the database's first
# start; changing it later does not change the stored password.
#
# Must match the user, password, and database in ekklesia.env's DATABASE_URL.
POSTGRES_USER=ekklesia
POSTGRES_DB=ekklesia
POSTGRES_PASSWORD=
+4
View File
@@ -0,0 +1,4 @@
# Cloudflare Tunnel connector token (`tunnel` only, compose.tunnel.yaml). Install as
# EKKLESIA_ENV_DIR/tunnel.env, owned by root with mode 600. Copy the token from the tunnel's page in
# the Cloudflare dashboard; it is the tunnel's credential.
TUNNEL_TOKEN=
+16
View File
@@ -0,0 +1,16 @@
# Host-based authentication for the reference deployment's PostgreSQL (ADR 0018, ADR 0044).
#
# Mounted read-only and selected with `-c hba_file=...` (deploy/compose.yaml), so it replaces the
# image's generated file entirely. Rules are matched top to bottom; the first match decides.
# TYPE DATABASE USER ADDRESS METHOD
# The container's own Unix socket: the image's first-start initialization and the health check.
# Nothing outside the container can reach it.
local all all trust
# Every network connection must be TLS (the server enforces TLS 1.3) and authenticate with SCRAM.
hostssl all all all scram-sha-256
# A network connection without TLS is refused outright, before authentication.
hostnossl all all all reject
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Prints freshly generated secrets for the reference deployment's env files (ADR 0044). Writes
# nothing: paste each line into the file named above it, then install that file root-owned with
# mode 600.
#
# sh deploy/scripts/gen-secrets.sh
#
# Every value is URL-safe (hexadecimal), so the database password needs no percent-encoding in
# DATABASE_URL.
set -eu
command -v openssl >/dev/null 2>&1 || {
echo "gen-secrets: openssl is required." >&2
exit 69
}
hex() {
openssl rand -hex "$1"
}
postgres_password=$(hex 24)
s3_access_key=$(hex 10)
s3_secret_key=$(hex 32)
cat <<EOF
# ekklesia.env
EKKLESIA_IDENTITY_SECRET=$(hex 32)
DATABASE_URL=postgresql://ekklesia:$postgres_password@postgres:5432/ekklesia
S3_ACCESS_KEY=$s3_access_key
S3_SECRET_KEY=$s3_secret_key
# postgres.env
POSTGRES_PASSWORD=$postgres_password
# object-storage.env
AWS_ACCESS_KEY_ID=$s3_access_key
AWS_SECRET_ACCESS_KEY=$s3_secret_key
EOF
+148
View File
@@ -0,0 +1,148 @@
#!/bin/sh
# Creates the installation-private CA (once) and the two server certificates the reference
# deployment needs (ADR 0044): `postgres` for the database and `object-storage` for SeaweedFS.
#
# sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
#
# Idempotent. An existing key or certificate is never overwritten: delete a file deliberately to
# reissue it. Ownership and modes are re-applied on every run, which needs root; a non-root run
# creates what is missing and then stops before that step, saying so. No private key is ever
# printed.
#
# Output (in the directory given):
# ca.key the CA's private key. Root only. Needed only to issue certificates; keep a
# copy offline and consider removing it from the server afterwards.
# ca.crt the CA certificate. Public. Mounted into api, migrate, and proxy.
# postgres.crt/.key for the name `postgres`; the key is owned by PostgreSQL's uid (70).
# object-storage.crt/.key for the name `object-storage`; the key is owned by SeaweedFS's uid (1000).
set -eu
umask 077
usage() {
echo "Usage: sh init-tls.sh <output directory>" >&2
exit 64
}
[ "$#" -eq 1 ] || usage
out=$1
case "$out" in
-*) usage ;;
esac
# The uids the two images run their servers as: postgres:18.4-alpine3.24 (postgres) and
# chrislusf/seaweedfs:4.28 (seaweed). Each server refuses or fails to read a key it cannot own.
POSTGRES_UID=${EKKLESIA_POSTGRES_UID:-70}
SEAWEED_UID=${EKKLESIA_SEAWEED_UID:-1000}
CA_DAYS=3650
SERVER_DAYS=825
mkdir -p "$out"
chmod 700 "$out"
created=""
# Only generating needs openssl. A later root-only pass that applies ownership to files that already
# exist runs without it, in any image that has a POSIX shell.
require_openssl() {
command -v openssl >/dev/null 2>&1 || {
echo "init-tls: openssl is required to create keys and certificates." >&2
exit 69
}
}
note_created() {
created="$created $1"
}
# A file this script would create already exists: keep it, and say so.
exists() {
if [ -e "$out/$1" ]; then
echo "init-tls: keeping existing $out/$1"
return 0
fi
return 1
}
if ! exists ca.key; then
require_openssl
openssl ecparam -name prime256v1 -genkey -noout -out "$out/ca.key"
note_created ca.key
fi
if ! exists ca.crt; then
require_openssl
openssl req -x509 -new -sha256 -days "$CA_DAYS" -key "$out/ca.key" -out "$out/ca.crt" \
-subj "/CN=Ekklesia installation CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash"
note_created ca.crt
fi
# issue <name>: a key and a certificate whose only subject alternative name is <name>, the Compose
# service name the client verifies against.
issue() {
name=$1
if ! exists "$name.key"; then
require_openssl
openssl ecparam -name prime256v1 -genkey -noout -out "$out/$name.key"
note_created "$name.key"
fi
if ! exists "$name.crt"; then
require_openssl
extensions=$(mktemp)
request=$(mktemp)
trap 'rm -f "$extensions" "$request"' EXIT
cat >"$extensions" <<EOF
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=serverAuth
subjectAltName=DNS:$name
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid
EOF
openssl req -new -sha256 -key "$out/$name.key" -subj "/CN=$name" -out "$request"
openssl x509 -req -sha256 -days "$SERVER_DAYS" -in "$request" \
-CA "$out/ca.crt" -CAkey "$out/ca.key" -CAcreateserial -CAserial "$out/ca.srl" \
-extfile "$extensions" -out "$out/$name.crt" 2>/dev/null
rm -f "$extensions" "$request"
trap - EXIT
note_created "$name.crt"
fi
}
issue postgres
issue object-storage
# Public material is world-readable; private keys are readable only by the process that needs them.
chmod 644 "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
chmod 600 "$out/ca.key" "$out/postgres.key" "$out/object-storage.key"
[ -e "$out/ca.srl" ] && chmod 600 "$out/ca.srl"
if [ -n "$created" ]; then
echo "init-tls: created$created in $out"
else
echo "init-tls: nothing to create in $out"
fi
if [ "$(id -u)" -ne 0 ]; then
echo "init-tls: ownership not applied: re-run as root to give postgres.key to uid $POSTGRES_UID and object-storage.key to uid $SEAWEED_UID." >&2
exit 77
fi
chown root:root "$out" "$out/ca.key" "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
[ -e "$out/ca.srl" ] && chown root:root "$out/ca.srl"
chown "$POSTGRES_UID:$POSTGRES_UID" "$out/postgres.key"
chown "$SEAWEED_UID:$SEAWEED_UID" "$out/object-storage.key"
# The directory itself must be traversable by the two server uids for their bind mounts.
chmod 755 "$out"
echo "init-tls: postgres.key is owned by uid $POSTGRES_UID and object-storage.key by uid $SEAWEED_UID (mode 600)."
echo
echo "Set EKKLESIA_TLS_DIR=$out in deploy/.env, and put this line in ekklesia.env (the CA"
echo "certificate is public; this is not a secret):"
echo
printf 'EKKLESIA_DATABASE_CA="%s"\n' "$(awk 'BEGIN { ORS = "\\n" } { print }' "$out/ca.crt")"
@@ -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.
+3 -1
View File
@@ -14,7 +14,9 @@
"authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js",
"boundaries": "node scripts/check-boundaries.ts",
"build": "turbo run build",
"ci:verify": "pnpm verify && pnpm infrastructure:config",
"ci:verify": "pnpm verify && pnpm infrastructure:config && pnpm deploy:config",
"deploy:config": "bash scripts/check-deploy-config.sh",
"deploy:smoke": "bash scripts/smoke-deploy.sh",
"doctor": "node scripts/run-doctor.ts",
"format": "biome format --write .",
"format:check": "biome format .",
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Validates the reference deployment's Compose files (ADR 0044) in each supported combination:
# base, base + proxy, and base + proxy + tunnel. Uses only the committed example env files, copied
# to a temporary directory, so it needs no secret and starts nothing.
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repository_root/deploy"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
for name in ekklesia postgres object-storage tunnel; do
cp "env/${name}.env.example" "$work/${name}.env"
done
cp env/compose.env.example "$work/compose.env"
cat >>"$work/compose.env" <<EOF_ENV
EKKLESIA_IMAGE=registry.example.org/ekklesia:0.0.0
EKKLESIA_ENV_DIR=${work}
EKKLESIA_HOSTNAME=ekklesia.example.org
EKKLESIA_STORAGE_HOSTNAME=storage.ekklesia.example.org
[email protected]
EOF_ENV
combinations=(
"compose.yaml"
"compose.yaml compose.proxy.yaml"
"compose.yaml compose.proxy.yaml compose.tunnel.yaml"
)
for combination in "${combinations[@]}"; do
arguments=()
for file in $combination; do
arguments+=(--file "$file")
done
if ! docker compose --env-file "$work/compose.env" "${arguments[@]}" --profile ops config --quiet; then
echo "Reference deployment Compose configuration is invalid: ${combination}" >&2
exit 1
fi
echo "valid: ${combination}"
done
+254
View File
@@ -0,0 +1,254 @@
// HTTP assertions for scripts/smoke-deploy.sh, run inside a disposable container on the reference
// deployment's `edge` network: every request goes through the proxy by its public hostname, exactly
// as a browser's would. Not a test of the API in isolation, and never run against a real
// installation.
//
// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy
//
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
const mode = process.argv[2];
const hostname = required("SMOKE_HOSTNAME");
const origin = `https://${hostname}`;
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
let failures = 0;
function check(condition, description) {
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
if (!condition) {
failures += 1;
}
}
/** A sign-in attempt that can only fail authentication; what matters is whether the API's
* per-client limiter (3 attempts per 10 seconds) counts it against the expected client. */
async function signInAttempt(headers = {}) {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
headers: { "content-type": "application/json", origin, ...headers },
method: "POST",
});
await response.arrayBuffer();
return response.status;
}
async function edgeChecks() {
const ready = await fetch(`${origin}/api/v1/health/ready`);
check(
ready.status === 200 && (await ready.text()) === '{"status":"ready"}',
"GET /api/v1/health/ready through the proxy is 200 ready",
);
const page = await fetch(`${origin}/`);
const html = await page.text();
check(page.status === 200, "GET / through the proxy is 200");
check(
page.headers.get("content-type") === "text/html; charset=utf-8" &&
html.includes('id="ekklesia-host"') &&
html.includes('src="/assets/ekklesia-dashboard/app.js"'),
"GET / is the Dashboard HTML",
);
check(
page.headers.get("x-frame-options") === "DENY" &&
page.headers.get("content-security-policy") === "frame-ancestors 'none'" &&
page.headers.get("referrer-policy") === "no-referrer",
"the Dashboard page keeps its security headers through the proxy",
);
const script = await fetch(`${origin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
const outside = await fetch(`${origin}/favicon.ico`);
check(
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
"a path the API does not serve is refused by the proxy, never forwarded",
);
const unknownApi = await fetch(`${origin}/api/v1/does-not-exist`);
check(
unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}',
"an unknown /api/* path reaches the API and gets its sanitized 404",
);
await storageDeliveryCheck();
// Every caller-supplied forwarding header rotates on every attempt. If any of them reached the
// API's client-address resolution, each attempt would land in a fresh limiter bucket and the
// fourth would not be limited.
const statuses = [];
for (let attempt = 1; attempt <= 4; attempt += 1) {
statuses.push(
await signInAttempt({
"cf-connecting-ip": `198.51.100.${attempt}`,
forwarded: `for=198.51.100.${attempt + 10}`,
"true-client-ip": `198.51.100.${attempt + 20}`,
"x-ekklesia-client-address": `198.51.100.${attempt + 50}`,
"x-forwarded-for": `198.51.100.${attempt + 30}`,
"x-real-ip": `198.51.100.${attempt + 40}`,
}),
);
}
check(
statuses.slice(0, 3).every((status) => status !== 429 && status < 500) && statuses[3] === 429,
`spoofed forwarding headers do not choose the client address (statuses ${statuses.join(",")})`,
);
}
async function storageDeliveryCheck() {
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
// over HTTPS on the private network and signs read URLs for the storage hostname.
const { createS3StorageProvider, createStorageProviderConfig } = await import(
"/opt/ekklesia/core/dist/storage/index.js"
);
const config = createStorageProviderConfig(
{
accessKey: required("S3_ACCESS_KEY"),
bucketPrefix: "ekklesia-smoke",
deliveryEndpoint: `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
endpoint: "https://object-storage:8443",
forcePathStyle: true,
region: "us-east-1",
secretKey: required("S3_SECRET_KEY"),
},
{ environment: "production" },
);
const provider = createS3StorageProvider(config);
try {
const bucket = "ekklesia-smoke-delivery";
const body = new TextEncoder().encode("delivered through the proxy\n");
await provider.ensureBucket(bucket);
await provider.put(bucket, "smoke/object.txt", body, {
contentLength: body.byteLength,
contentType: "text/plain",
});
const readUrl = await provider.createReadUrl(bucket, "smoke/object.txt", {
expiresInSeconds: 300,
});
const url = new URL(readUrl.url);
check(
url.origin === `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
"a signed read URL names the storage hostname",
);
const delivered = await fetch(url);
const text = await delivered.text();
check(
delivered.status === 200 && text === "delivered through the proxy\n",
"a signed read URL verifies through the proxy (original Host preserved)",
);
check(
(delivered.headers.get("cache-control") ?? "").startsWith("private"),
"a delivered object is Cache-Control: private",
);
await provider.delete(bucket, "smoke/object.txt");
} finally {
await provider.close();
}
}
async function tunnelChecks() {
// From the connector's fixed address, CF-Connecting-IP is the client: one visitor is limited on
// its fourth attempt, and a different visitor is not limited by the first one's attempts.
const first = [];
for (let attempt = 1; attempt <= 4; attempt += 1) {
first.push(await signInAttempt({ "cf-connecting-ip": "203.0.113.10" }));
}
check(
first.slice(0, 3).every((status) => status !== 429 && status < 500) && first[3] === 429,
`the connector's CF-Connecting-IP identifies the visitor (statuses ${first.join(",")})`,
);
const second = await signInAttempt({ "cf-connecting-ip": "203.0.113.11" });
check(
second !== 429 && second < 500,
`a second visitor behind the connector has its own limiter bucket (status ${second})`,
);
}
/** The deployed browser authentication contract (launch checklist item 9), as the initial owner
* created by the image's own CLI (item 10). */
async function sessionChecks() {
for (const path of [
"/api/auth/get-session",
"/api/auth/list-sessions",
"/api/auth/update-session",
"/api/auth/change-password",
]) {
const response = await fetch(`${origin}${path}`, {
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
headers: { origin },
});
check(
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
`${path} is unavailable through the proxy`,
);
}
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({
email: required("SMOKE_OWNER_EMAIL"),
password: required("SMOKE_OWNER_PASSWORD"),
}),
headers: { "content-type": "application/json", origin },
method: "POST",
});
const signInBody = await signIn.text();
const setCookie = signIn.headers.get("set-cookie") ?? "";
check(
signIn.status === 200 && signInBody === '{"ok":true}',
'sign-in returns the fixed {"ok":true}',
);
const attributes = setCookie.toLowerCase();
check(
attributes.includes("httponly") &&
attributes.includes("secure") &&
attributes.includes("samesite=lax") &&
!attributes.includes("domain="),
"the session cookie is host-only, HttpOnly, Secure, and SameSite=Lax",
);
const cookie = setCookie.split(";")[0] ?? "";
const principal = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
const principalBody = await principal.json();
check(
principal.status === 200 && principalBody.kind === "authenticated",
"the session resolves an authenticated principal through the proxy",
);
const signOut = await fetch(`${origin}/api/auth/sign-out`, {
body: "{}",
headers: { "content-type": "application/json", cookie, origin },
method: "POST",
});
await signOut.arrayBuffer();
const after = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
check(
signOut.status === 200 && (await after.json()).kind === "anonymous",
"sign-out ends the session",
);
}
if (mode === "edge") {
await edgeChecks();
} else if (mode === "tunnel") {
await tunnelChecks();
} else if (mode === "session") {
await sessionChecks();
} else {
throw new Error('Mode must be "edge", "tunnel", or "session".');
}
if (failures > 0) {
console.log(`${failures} check(s) failed.`);
process.exit(1);
}
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env bash
# End-to-end smoke test of the reference deployment (ADR 0044): deploy/compose.yaml with
# deploy/compose.proxy.yaml, a platform-only image, and a disposable PostgreSQL and SeaweedFS that
# exist only for this run. CI runs it; it is safe to run on a workstation.
#
# bash scripts/smoke-deploy.sh builds the image first
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
#
# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test
# run has no public hostname. Never use that option in an installation.
#
# Follows the documented first boot: TLS material, secrets, data services, an explicit migration
# apply against this run's own fresh database, then the API and proxy. Everything it starts, and
# every file it writes, is removed on exit.
#
# Bind mounts are resolved by the Docker daemon, on its host. When this script runs inside a CI job
# container that drives the host's Docker, its own files are not at those paths, so every file a
# container mounts is staged under an identical path in the Docker host's /tmp first.
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repository_root"
for command in docker openssl awk; do
command -v "$command" >/dev/null || {
echo "smoke-deploy: $command is required." >&2
exit 1
}
done
docker compose version >/dev/null 2>&1 || {
echo "smoke-deploy: Docker Compose is required." >&2
exit 1
}
# The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither
# root nor sudo on the host.
postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15"
run_id="$(openssl rand -hex 4)"
project="ekklesia-deploy-smoke-${run_id}"
work="$(mktemp -d)"
image="${EKKLESIA_SMOKE_IMAGE:-}"
built_image=""
hostname="ekklesia.smoke.test"
storage_hostname="storage.smoke.test"
# A per-run edge subnet, so concurrent runs and the host's own networks do not collide. Fixed
# addresses come from the upper half, outside Docker's dynamic range, exactly as documented.
octet=$((RANDOM % 200 + 20))
edge_subnet="10.249.${octet}.0/24"
edge_range="10.249.${octet}.0/25"
proxy_address="10.249.${octet}.254"
tunnel_address="10.249.${octet}.253"
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
session_client_address="10.249.${octet}.252"
# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work`
# on a workstation; a staged copy on the Docker host when this script runs in a job container.
if [[ -f /.dockerenv ]]; then
daemon_work="/tmp/${project}"
docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$postgres_image" -p "/host-tmp/${project}"
# stage <path relative to work>: copy it to the same relative path under daemon_work, keeping
# numeric ownership and modes.
stage() {
tar -C "$work" -cf - "$1" |
docker run --rm -i --entrypoint tar -v "$daemon_work:/w" "$postgres_image" -xpf - -C /w
}
else
daemon_work="$work"
stage() { :; }
fi
compose=(
docker compose
--project-name "$project"
--project-directory "$daemon_work/deploy"
--env-file "$work/compose.env"
--file deploy/compose.yaml
--file deploy/compose.proxy.yaml
)
cleanup() {
local status=$?
trap - EXIT INT TERM
if [[ -f "$work/compose.env" ]]; then
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
fi
# Some files are owned by the PostgreSQL and SeaweedFS uids by now; remove them as root, in the
# same image that set that ownership. In a job container the daemon cannot see `work` (and the
# job itself runs as root), so only the staged copy on the Docker host needs this.
if [[ "$daemon_work" == "$work" ]]; then
docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \
>/dev/null 2>&1 || true
else
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \
>/dev/null 2>&1 || true
fi
rm -rf "$work" || true
if [[ -n "$built_image" ]]; then
docker image rm --force "$built_image" >/dev/null 2>&1 || true
fi
exit "$status"
}
trap cleanup EXIT INT TERM
step() {
echo "==> $*"
}
fail() {
echo "smoke-deploy: $*" >&2
exit 1
}
if [[ -z "$image" ]]; then
step "Building the platform-only image"
built_image="ekklesia-deploy-smoke:${run_id}"
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null
image="$built_image"
fi
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
cp -R deploy "$work/deploy"
stage deploy
step "Creating the installation-private CA and server certificates"
init_status=0
sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$?
if ((init_status != 0 && init_status != 77)); then
fail "init-tls.sh failed to create TLS material."
fi
stage tls
# The root-only ownership pass, exactly as an operator would re-run the script with root.
ownership_output="$(
docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \
-v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls
)" || fail "init-tls.sh could not apply ownership."
database_ca_line="$(grep '^EKKLESIA_DATABASE_CA=' <<<"$ownership_output")" ||
fail "init-tls.sh did not print the EKKLESIA_DATABASE_CA line."
# A second run must create nothing and still succeed.
rerun_output="$(
docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \
-v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls
)" || fail "init-tls.sh failed on a second run."
grep -q 'nothing to create' <<<"$rerun_output" || fail "init-tls.sh is not idempotent."
step "Writing env files from the examples, with freshly generated secrets"
secrets="$(sh deploy/scripts/gen-secrets.sh)"
secret() {
awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' <<<"$secrets"
}
postgres_password="$(secret POSTGRES_PASSWORD)"
s3_access_key="$(secret S3_ACCESS_KEY)"
s3_secret_key="$(secret S3_SECRET_KEY)"
identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)"
mkdir -p "$work/env"
# set_env <file> <key> <value>: replaces an uncommented KEY= line, keeping the value byte-exact.
set_env() {
KEY="$2" VALUE="$3" awk '
BEGIN { found = 0 }
index($0, ENVIRON["KEY"] "=") == 1 { print ENVIRON["KEY"] "=" ENVIRON["VALUE"]; found = 1; next }
{ print }
END { if (!found) print ENVIRON["KEY"] "=" ENVIRON["VALUE"] }
' "$1" >"$1.next"
mv "$1.next" "$1"
}
for name in ekklesia postgres object-storage; do
cp "deploy/env/${name}.env.example" "$work/env/${name}.env"
done
ekklesia_env="$work/env/ekklesia.env"
set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia"
set_env "$ekklesia_env" EKKLESIA_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}"
set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}"
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "https://${hostname}"
set_env "$ekklesia_env" EKKLESIA_IDENTITY_SECRET "$identity_secret"
set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}"
set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key"
set_env "$ekklesia_env" S3_SECRET_KEY "$s3_secret_key"
set_env "$work/env/postgres.env" POSTGRES_PASSWORD "$postgres_password"
set_env "$work/env/object-storage.env" AWS_ACCESS_KEY_ID "$s3_access_key"
set_env "$work/env/object-storage.env" AWS_SECRET_ACCESS_KEY "$s3_secret_key"
chmod 600 "$work"/env/*.env
# TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option.
awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \
deploy/Caddyfile.example >"$work/Caddyfile"
grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy."
stage Caddyfile
cat >"$work/compose.env" <<EOF
EKKLESIA_IMAGE=${image}
EKKLESIA_ENV_DIR=${work}/env
EKKLESIA_TLS_DIR=${daemon_work}/tls
EKKLESIA_HOSTNAME=${hostname}
EKKLESIA_STORAGE_HOSTNAME=${storage_hostname}
[email protected]
EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile
EKKLESIA_PUBLISH_ADDRESS=127.0.0.1
EKKLESIA_HTTP_PORT=0
EKKLESIA_HTTPS_PORT=0
EKKLESIA_EDGE_SUBNET=${edge_subnet}
EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range}
EKKLESIA_PROXY_ADDRESS=${proxy_address}
EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
EOF
step "Starting PostgreSQL and object storage"
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1 ||
fail "PostgreSQL and object storage did not become healthy."
step "Planning, then explicitly applying, migrations against this run's fresh database"
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "migrate (apply) failed."
step "Starting the API and the proxy"
"${compose[@]}" up --detach --wait --wait-timeout 180 >/dev/null 2>&1 ||
fail "the API and proxy did not become healthy."
# Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA
# for their direct connection to object-storage.
for _ in $(seq 1 30); do
if "${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/root.crt \
>"$work/caddy-root.crt" 2>/dev/null && [[ -s "$work/caddy-root.crt" ]]; then
break
fi
sleep 1
done
[[ -s "$work/caddy-root.crt" ]] || fail "the test proxy's internal CA did not appear."
cat "$work/caddy-root.crt" "$work/tls/ca.crt" >"$work/client-ca.pem"
cp scripts/lib/deploy-smoke-client.mjs "$work/client.mjs"
stage client-ca.pem
stage client.mjs
edge_network="${project}_edge"
client() {
local mode="$1"
shift
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--network "$edge_network" "$@" \
--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}" \
-e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \
-e SMOKE_STORAGE_HOSTNAME="$storage_hostname" \
-e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \
-v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \
-v "$daemon_work/client-ca.pem:/smoke/client-ca.pem:ro" \
--entrypoint node "$image" /smoke/client.mjs "$mode"
}
step "Checking the API, Dashboard, storage delivery, and client-address chain through the proxy"
client edge || fail "edge checks failed."
step "Checking the tunnel connector's client-address path from its fixed address"
client tunnel --ip "$tunnel_address" || fail "tunnel checks failed."
step "Creating the initial owner with the image's own CLI, then signing in and out through the proxy"
owner_email="[email protected]"
owner_password="$(openssl rand -hex 16)"
printf '%s' "$owner_password" |
"${compose[@]}" run --rm --no-deps -T migrate node core/dist/cli/create-initial-owner.js \
--email "$owner_email" >/dev/null || fail "creating the initial owner failed."
echo "ok the initial owner was created from the image (password on standard input)"
client session --ip "$session_client_address" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
fail "session checks failed."
step "Proving the API's database connection is certificate-verified TLS"
api_container="$("${compose[@]}" ps -q api)"
api_data_address="$(
docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \
"$api_container"
)"
[[ -n "$api_data_address" ]] || fail "could not find the API's database-network address."
psql_as_superuser() {
"${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1"
}
tls_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND s.ssl AND s.version = 'TLSv1.3'")"
plain_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND NOT s.ssl")"
((tls_connections > 0)) || fail "the API holds no TLS 1.3 database connection."
((plain_connections == 0)) || fail "the API holds a database connection without TLS."
echo "ok the API's database connections are TLS 1.3 (${tls_connections}), none plaintext"
# PostgreSQL itself refuses a network connection without TLS.
if "${compose[@]}" exec -T -e PGPASSWORD="$postgres_password" postgres \
psql "host=postgres user=ekklesia dbname=ekklesia sslmode=disable" -Atc 'SELECT 1' >/dev/null 2>&1; then
fail "PostgreSQL accepted a plaintext network connection."
fi
echo "ok PostgreSQL refuses a plaintext network connection"
# The server cannot see whether a client verified it, so verification is proven by refusal: the
# same command fails against a CA that did not issue the certificate, and against a host the
# certificate does not name.
openssl ecparam -name prime256v1 -genkey -noout -out "$work/other-ca.key" 2>/dev/null
openssl req -x509 -new -sha256 -days 1 -key "$work/other-ca.key" -out "$work/other-ca.crt" \
-subj "/CN=Unrelated CA" 2>/dev/null
# expect_connection_refused <description> <compose run arguments...>: the command must fail, and fail
# at the connection, not on a configuration error that would prove nothing about verification.
expect_connection_refused() {
local description="$1"
shift
local output
if output="$("${compose[@]}" run --rm --no-deps "$@" 2>&1)"; then
fail "the database connection accepted ${description}."
fi
grep -q 'MIGRATION_CONNECTION_FAILED' <<<"$output" ||
fail "rejecting ${description} failed for a reason other than the connection."
}
expect_connection_refused "a certificate from a CA it was not given" \
-e EKKLESIA_DATABASE_CA="$(cat "$work/other-ca.crt")" migrate
echo "ok the database connection rejects a certificate its CA did not issue"
postgres_address="$(
docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \
"$("${compose[@]}" ps -q postgres)"
)"
expect_connection_refused "a certificate for a different host name" \
-e DATABASE_URL="postgresql://ekklesia:${postgres_password}@${postgres_address}:5432/ekklesia" migrate
echo "ok the database connection rejects a certificate that does not name the host"
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "the control connection failed."
echo "ok the same connection succeeds with the installation CA and the name postgres"
step "Running Doctor from the image"
doctor_output="$("${compose[@]}" run --rm --no-deps migrate node core/dist/cli/doctor.js 2>&1)" ||
fail "Doctor reported the installation not ready."
grep -q 'Result: READY' <<<"$doctor_output" || fail "Doctor did not report READY."
grep -q 'TLS required with certificate and hostname verification (explicit private CA supplied)' \
<<<"$doctor_output" || fail "Doctor did not report verify-full with the private CA."
grep -q '✓ Object storage reachable' <<<"$doctor_output" ||
fail "Doctor could not reach object storage over HTTPS."
for value in "$postgres_password" "$s3_secret_key" "$identity_secret"; do
if grep -qF -- "$value" <<<"$doctor_output"; then
fail "Doctor output contains a secret."
fi
done
echo "ok Doctor: READY, verify-full with the private CA, storage reachable, no secret printed"
api_logs="$("${compose[@]}" logs --no-color api 2>&1)"
for value in "$postgres_password" "$s3_secret_key" "$identity_secret" "$owner_password"; do
if grep -qF -- "$value" <<<"$api_logs"; then
fail "the API's logs contain a secret."
fi
done
restarts="$(docker inspect --format '{{.RestartCount}}' "$api_container")"
((restarts == 0)) || fail "the API restarted ${restarts} time(s)."
echo "ok the API logged no secret and never restarted"
echo "Reference deployment smoke test passed."
+231
View File
@@ -0,0 +1,231 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
/**
* Structural guarantees of the reference deployment (ADR 0044), checked on the files themselves so
* an edit cannot quietly weaken them. `scripts/smoke-deploy.sh` proves the same configuration
* actually runs; this proves it still says what the documentation promises.
*/
interface ComposeService {
build?: unknown;
cap_add?: string[];
cap_drop?: string[];
command?: string[];
env_file?: string[];
environment?: Record<string, string>;
healthcheck?: { disable?: boolean };
image?: string;
init?: boolean;
networks?: Record<string, { ipv4_address?: string } | null>;
ports?: string[];
profiles?: string[];
read_only?: boolean;
security_opt?: string[];
tmpfs?: string[];
}
interface ComposeDocument {
networks?: Record<
string,
{ internal?: boolean; ipam?: { config?: { subnet?: string; ip_range?: string }[] } }
>;
services?: Record<string, ComposeService>;
}
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const deployDirectory = path.join(repositoryRoot, "deploy");
async function compose(file: string): Promise<ComposeDocument> {
// `merge: true` resolves the base file's `<<:` hardening anchors exactly as Compose does.
return parse(await readFile(path.join(deployDirectory, file), "utf8"), {
merge: true,
}) as ComposeDocument;
}
async function text(file: string): Promise<string> {
return readFile(path.join(deployDirectory, file), "utf8");
}
function envAssignments(source: string): Map<string, string> {
const assignments = new Map<string, string>();
for (const line of source.split("\n")) {
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/u.exec(line);
if (match?.[1]) {
assignments.set(match[1], match[2] ?? "");
}
}
return assignments;
}
const DIGEST_PINNED = /^[^\s:@]+(?::[^\s@]+)?@sha256:[0-9a-f]{64}$/u;
describe("reference deployment compose files", () => {
it("keeps PostgreSQL on its internal network only, and publishes no port from the base", async () => {
const base = await compose("compose.yaml");
const services = base.services ?? {};
expect(Object.keys(services).sort()).toEqual(["api", "migrate", "object-storage", "postgres"]);
expect(Object.keys(services.postgres?.networks ?? {})).toEqual(["data"]);
expect(base.networks?.data?.internal).toBe(true);
expect(base.networks?.storage?.internal).toBe(true);
for (const [name, service] of Object.entries(services)) {
expect(service.ports, name).toBeUndefined();
}
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
const document = await compose(overlay);
expect(document.services?.postgres, overlay).toBeUndefined();
}
});
it("pins every third-party image by digest and runs the API only from EKKLESIA_IMAGE", async () => {
const documents = await Promise.all(
["compose.yaml", "compose.proxy.yaml", "compose.tunnel.yaml"].map(compose),
);
for (const document of documents) {
for (const [name, service] of Object.entries(document.services ?? {})) {
expect(service.build, name).toBeUndefined();
if (service.image === undefined) {
continue;
}
if (name === "api" || name === "migrate") {
expect(service.image, name).toMatch(/^\$\{EKKLESIA_IMAGE:\?/u);
} else {
expect(service.image, name).toMatch(DIGEST_PINNED);
}
}
}
});
it("hardens api and migrate, keeps migrate operator-only, and never lets it apply by default", async () => {
const services = (await compose("compose.yaml")).services ?? {};
for (const name of ["api", "migrate"]) {
const service = services[name];
expect(service?.init, name).toBe(true);
expect(service?.read_only, name).toBe(true);
expect(service?.tmpfs, name).toEqual(["/tmp"]);
expect(service?.cap_drop, name).toEqual(["ALL"]);
expect(service?.security_opt, name).toContain("no-new-privileges:true");
expect(service?.environment?.NODE_EXTRA_CA_CERTS, name).toBe("/run/ekklesia/tls/ca.crt");
}
const migrate = services.migrate;
expect(migrate?.profiles).toEqual(["ops"]);
expect(migrate?.healthcheck?.disable).toBe(true);
expect(migrate?.command).toEqual(["node", "core/dist/cli/plan-migrations.js"]);
expect(JSON.stringify(services)).not.toContain("apply-migrations");
});
it("serves PostgreSQL and object storage over TLS from the installation-private CA", async () => {
const services = (await compose("compose.yaml")).services ?? {};
expect(services.postgres?.command).toEqual(
expect.arrayContaining([
"ssl=on",
"ssl_min_protocol_version=TLSv1.3",
"hba_file=/etc/ekklesia/pg_hba.conf",
]),
);
expect(services["object-storage"]?.command).toEqual(
expect.arrayContaining(["-s3.port.https=8443"]),
);
const hba = (await text("postgres/pg_hba.conf"))
.split("\n")
.filter((line) => line.trim() !== "" && !line.trim().startsWith("#"))
.map((line) => line.trim().split(/\s+/u));
expect(hba).toEqual([
["local", "all", "all", "trust"],
["hostssl", "all", "all", "all", "scram-sha-256"],
["hostnossl", "all", "all", "all", "reject"],
]);
});
it("makes the proxy the API's only trusted peer, at a fixed address outside the dynamic range", async () => {
const proxyOverlay = await compose("compose.proxy.yaml");
const proxy = proxyOverlay.services?.proxy;
const proxyAddress = proxy?.networks?.edge?.ipv4_address;
expect(proxyAddress).toBe("${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}");
expect(proxyOverlay.services?.api?.environment).toEqual({
EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for",
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
});
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
{
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
subnet: "${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}",
},
]);
expect(proxy?.cap_drop).toEqual(["ALL"]);
expect(proxy?.cap_add).toEqual(["NET_BIND_SERVICE"]);
const tunnel = (await compose("compose.tunnel.yaml")).services?.tunnel;
expect(tunnel?.networks?.edge?.ipv4_address).toBe("${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}");
expect(tunnel?.env_file).toEqual(["${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env"]);
});
it("routes only the API's own paths, overwrites the forwarding header, and never ships local_certs", async () => {
const caddyfile = await text("Caddyfile.example");
const directives = caddyfile
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.join("\n");
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
expect(directives).toContain("header_up -CF-Connecting-IP");
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
expect(directives).toContain("header_up Host {host}");
expect(directives).toContain("tls_trust_pool file /etc/caddy/ekklesia-ca.crt");
// Caddy's internal CA is a test-only substitution made by scripts/smoke-deploy.sh.
expect(directives).not.toContain("local_certs");
expect(directives).not.toMatch(/tls\s+internal/u);
});
});
describe("reference deployment env examples", () => {
it("contain no secret values", async () => {
const secretKeys = new Map([
["env/ekklesia.env.example", ["EKKLESIA_IDENTITY_SECRET", "S3_ACCESS_KEY", "S3_SECRET_KEY"]],
["env/postgres.env.example", ["POSTGRES_PASSWORD"]],
["env/object-storage.env.example", ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]],
["env/tunnel.env.example", ["TUNNEL_TOKEN"]],
]);
for (const [file, keys] of secretKeys) {
const assignments = envAssignments(await text(file));
for (const key of keys) {
expect(assignments.get(key), `${file} ${key}`).toBe("");
}
}
const ekklesia = envAssignments(await text("env/ekklesia.env.example"));
expect(ekklesia.get("DATABASE_URL")).toBe(
"postgresql://<user>:<password>@postgres:5432/<database>",
);
expect(ekklesia.get("EKKLESIA_DATABASE_TLS_MODE")).toBe("verify-full");
expect(ekklesia.get("S3_ENDPOINT")).toBe("https://object-storage:8443");
});
it("names every variable Core's host configuration reads", async () => {
// The single place executables read the environment (core/src/host/configuration.ts). A new
// variable there without a line here would leave an operator guessing.
const hostConfiguration = await readFile(
path.join(repositoryRoot, "core", "src", "host", "configuration.ts"),
"utf8",
);
const read = new Set(
[...hostConfiguration.matchAll(/environment\.([A-Z][A-Z0-9_]+)/gu)].map((match) => match[1]),
);
const example = await text("env/ekklesia.env.example");
// Set by the image, not by an operator.
read.delete("NODE_ENV");
// Guards against a pattern that silently stops matching and checks nothing.
expect(read.size).toBeGreaterThan(20);
for (const name of read) {
expect(example, String(name)).toMatch(new RegExp(`^#? ?${name}=`, "mu"));
}
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
});
});