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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-01 20:59:38 -05:00
co-authored by Claude Opus 5.5
parent b593abe7a5
commit 167b4c4f14
21 changed files with 1902 additions and 116 deletions
+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