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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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:
|
||||
Vendored
+29
@@ -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
|
||||
Vendored
+71
@@ -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
@@ -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=
|
||||
Vendored
+8
@@ -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=
|
||||
Vendored
+4
@@ -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=
|
||||
@@ -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
|
||||
Executable
+39
@@ -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
|
||||
Executable
+148
@@ -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")"
|
||||
Reference in New Issue
Block a user