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
+231
View File
@@ -0,0 +1,231 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
/**
* Structural guarantees of the reference deployment (ADR 0044), checked on the files themselves so
* an edit cannot quietly weaken them. `scripts/smoke-deploy.sh` proves the same configuration
* actually runs; this proves it still says what the documentation promises.
*/
interface ComposeService {
build?: unknown;
cap_add?: string[];
cap_drop?: string[];
command?: string[];
env_file?: string[];
environment?: Record<string, string>;
healthcheck?: { disable?: boolean };
image?: string;
init?: boolean;
networks?: Record<string, { ipv4_address?: string } | null>;
ports?: string[];
profiles?: string[];
read_only?: boolean;
security_opt?: string[];
tmpfs?: string[];
}
interface ComposeDocument {
networks?: Record<
string,
{ internal?: boolean; ipam?: { config?: { subnet?: string; ip_range?: string }[] } }
>;
services?: Record<string, ComposeService>;
}
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const deployDirectory = path.join(repositoryRoot, "deploy");
async function compose(file: string): Promise<ComposeDocument> {
// `merge: true` resolves the base file's `<<:` hardening anchors exactly as Compose does.
return parse(await readFile(path.join(deployDirectory, file), "utf8"), {
merge: true,
}) as ComposeDocument;
}
async function text(file: string): Promise<string> {
return readFile(path.join(deployDirectory, file), "utf8");
}
function envAssignments(source: string): Map<string, string> {
const assignments = new Map<string, string>();
for (const line of source.split("\n")) {
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/u.exec(line);
if (match?.[1]) {
assignments.set(match[1], match[2] ?? "");
}
}
return assignments;
}
const DIGEST_PINNED = /^[^\s:@]+(?::[^\s@]+)?@sha256:[0-9a-f]{64}$/u;
describe("reference deployment compose files", () => {
it("keeps PostgreSQL on its internal network only, and publishes no port from the base", async () => {
const base = await compose("compose.yaml");
const services = base.services ?? {};
expect(Object.keys(services).sort()).toEqual(["api", "migrate", "object-storage", "postgres"]);
expect(Object.keys(services.postgres?.networks ?? {})).toEqual(["data"]);
expect(base.networks?.data?.internal).toBe(true);
expect(base.networks?.storage?.internal).toBe(true);
for (const [name, service] of Object.entries(services)) {
expect(service.ports, name).toBeUndefined();
}
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
const document = await compose(overlay);
expect(document.services?.postgres, overlay).toBeUndefined();
}
});
it("pins every third-party image by digest and runs the API only from EKKLESIA_IMAGE", async () => {
const documents = await Promise.all(
["compose.yaml", "compose.proxy.yaml", "compose.tunnel.yaml"].map(compose),
);
for (const document of documents) {
for (const [name, service] of Object.entries(document.services ?? {})) {
expect(service.build, name).toBeUndefined();
if (service.image === undefined) {
continue;
}
if (name === "api" || name === "migrate") {
expect(service.image, name).toMatch(/^\$\{EKKLESIA_IMAGE:\?/u);
} else {
expect(service.image, name).toMatch(DIGEST_PINNED);
}
}
}
});
it("hardens api and migrate, keeps migrate operator-only, and never lets it apply by default", async () => {
const services = (await compose("compose.yaml")).services ?? {};
for (const name of ["api", "migrate"]) {
const service = services[name];
expect(service?.init, name).toBe(true);
expect(service?.read_only, name).toBe(true);
expect(service?.tmpfs, name).toEqual(["/tmp"]);
expect(service?.cap_drop, name).toEqual(["ALL"]);
expect(service?.security_opt, name).toContain("no-new-privileges:true");
expect(service?.environment?.NODE_EXTRA_CA_CERTS, name).toBe("/run/ekklesia/tls/ca.crt");
}
const migrate = services.migrate;
expect(migrate?.profiles).toEqual(["ops"]);
expect(migrate?.healthcheck?.disable).toBe(true);
expect(migrate?.command).toEqual(["node", "core/dist/cli/plan-migrations.js"]);
expect(JSON.stringify(services)).not.toContain("apply-migrations");
});
it("serves PostgreSQL and object storage over TLS from the installation-private CA", async () => {
const services = (await compose("compose.yaml")).services ?? {};
expect(services.postgres?.command).toEqual(
expect.arrayContaining([
"ssl=on",
"ssl_min_protocol_version=TLSv1.3",
"hba_file=/etc/ekklesia/pg_hba.conf",
]),
);
expect(services["object-storage"]?.command).toEqual(
expect.arrayContaining(["-s3.port.https=8443"]),
);
const hba = (await text("postgres/pg_hba.conf"))
.split("\n")
.filter((line) => line.trim() !== "" && !line.trim().startsWith("#"))
.map((line) => line.trim().split(/\s+/u));
expect(hba).toEqual([
["local", "all", "all", "trust"],
["hostssl", "all", "all", "all", "scram-sha-256"],
["hostnossl", "all", "all", "all", "reject"],
]);
});
it("makes the proxy the API's only trusted peer, at a fixed address outside the dynamic range", async () => {
const proxyOverlay = await compose("compose.proxy.yaml");
const proxy = proxyOverlay.services?.proxy;
const proxyAddress = proxy?.networks?.edge?.ipv4_address;
expect(proxyAddress).toBe("${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}");
expect(proxyOverlay.services?.api?.environment).toEqual({
EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for",
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
});
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
{
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
subnet: "${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}",
},
]);
expect(proxy?.cap_drop).toEqual(["ALL"]);
expect(proxy?.cap_add).toEqual(["NET_BIND_SERVICE"]);
const tunnel = (await compose("compose.tunnel.yaml")).services?.tunnel;
expect(tunnel?.networks?.edge?.ipv4_address).toBe("${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}");
expect(tunnel?.env_file).toEqual(["${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env"]);
});
it("routes only the API's own paths, overwrites the forwarding header, and never ships local_certs", async () => {
const caddyfile = await text("Caddyfile.example");
const directives = caddyfile
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.join("\n");
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
expect(directives).toContain("header_up -CF-Connecting-IP");
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
expect(directives).toContain("header_up Host {host}");
expect(directives).toContain("tls_trust_pool file /etc/caddy/ekklesia-ca.crt");
// Caddy's internal CA is a test-only substitution made by scripts/smoke-deploy.sh.
expect(directives).not.toContain("local_certs");
expect(directives).not.toMatch(/tls\s+internal/u);
});
});
describe("reference deployment env examples", () => {
it("contain no secret values", async () => {
const secretKeys = new Map([
["env/ekklesia.env.example", ["EKKLESIA_IDENTITY_SECRET", "S3_ACCESS_KEY", "S3_SECRET_KEY"]],
["env/postgres.env.example", ["POSTGRES_PASSWORD"]],
["env/object-storage.env.example", ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]],
["env/tunnel.env.example", ["TUNNEL_TOKEN"]],
]);
for (const [file, keys] of secretKeys) {
const assignments = envAssignments(await text(file));
for (const key of keys) {
expect(assignments.get(key), `${file} ${key}`).toBe("");
}
}
const ekklesia = envAssignments(await text("env/ekklesia.env.example"));
expect(ekklesia.get("DATABASE_URL")).toBe(
"postgresql://<user>:<password>@postgres:5432/<database>",
);
expect(ekklesia.get("EKKLESIA_DATABASE_TLS_MODE")).toBe("verify-full");
expect(ekklesia.get("S3_ENDPOINT")).toBe("https://object-storage:8443");
});
it("names every variable Core's host configuration reads", async () => {
// The single place executables read the environment (core/src/host/configuration.ts). A new
// variable there without a line here would leave an operator guessing.
const hostConfiguration = await readFile(
path.join(repositoryRoot, "core", "src", "host", "configuration.ts"),
"utf8",
);
const read = new Set(
[...hostConfiguration.matchAll(/environment\.([A-Z][A-Z0-9_]+)/gu)].map((match) => match[1]),
);
const example = await text("env/ekklesia.env.example");
// Set by the image, not by an operator.
read.delete("NODE_ENV");
// Guards against a pattern that silently stops matching and checks nothing.
expect(read.size).toBeGreaterThan(20);
for (const name of read) {
expect(example, String(name)).toMatch(new RegExp(`^#? ?${name}=`, "mu"));
}
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
});
});