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
+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")"