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
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:
co-authored by
Claude Opus 5.5
parent
b593abe7a5
commit
167b4c4f14
+302
-97
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user