diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..afaf6d0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Build context for deploy/Dockerfile (ADR 0044). The image installs its own dependencies with +# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the +# host may enter the context, and no secret or environment file may enter any layer. + +# Host-installed dependencies and build outputs: the image produces its own. +**/node_modules +**/dist +**/.turbo +**/coverage +**/*.tsbuildinfo + +# Environment and secret material. Configuration reaches the container only through its runtime +# environment; `.env.example` is excluded too, since nothing in the image reads it. +**/.env +**/.env.* + +# Repository and tooling metadata the image never reads. +.git +.github +.gitea +.forgejo +.vscode +.idea +**/*.log +**/.DS_Store diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03c8a9e..8265992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,3 +103,35 @@ jobs: - name: Exercise transactional migrations against PostgreSQL run: pnpm test:migrations:integration + + image-build: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + # Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI + # has no buildx plugin. + DOCKER_BUILDKIT: "1" + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # `actions/checkout` defaults to leaving the job token in `.git/config`, where any later + # step — including one a pull request added to this file — could read or reuse it. Nothing + # in this workflow pushes, so the credential is never needed after checkout. + persist-credentials: false + + # The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image + # builds, validates, and assembles with zero modules. Build only — no registry login, no + # credentials, no push, no tag anywhere but this runner's local image store. + - name: Build the production image without modules + run: >- + docker build + --file deploy/Dockerfile + --build-arg REVISION="$GITHUB_SHA" + --tag ekklesia-ci:"$GITHUB_SHA" + . + + - name: Remove the image from the runner + if: always() + run: docker image rm --force ekklesia-ci:"$GITHUB_SHA" || true diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..709eaf7 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,131 @@ +# Ekklesia's one production image (ADR 0044): the `api` service and every operator-run CLI, from one +# build. See deploy/README.md for how to build and run it. +# +# The build context is an already-assembled workspace: the platform, zero or more module packages in +# `modules/`, and the `pnpm-lock.yaml` that describes exactly that set. This file clones nothing +# and chooses no modules. With an empty or absent `modules/`, it builds the platform alone. +# +# No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image. +# The built-in frontend supports everything below, including cache mounts. + +# Node.js 24.18.0 (matching `.node-version`) on Debian 13 slim, pinned by its multi-architecture +# index digest. glibc rather than musl, because module dependencies such as sharp ship glibc +# prebuilt binaries. Every stage starts from this exact image. +FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS base + +# --------------------------------------------------------------------------------------------------- +# toolchain: pnpm through Corepack, at exactly the root `packageManager` version. +# --------------------------------------------------------------------------------------------------- +FROM base AS toolchain +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + PNPM_HOME=/pnpm \ + TURBO_TELEMETRY_DISABLED=1 \ + DO_NOT_TRACK=1 +WORKDIR /opt/ekklesia +COPY package.json ./ +# Corepack reads `packageManager` (pnpm@) from the root manifest, so the version is +# declared once, in the repository, and never restated here. +RUN corepack enable && corepack install + +# --------------------------------------------------------------------------------------------------- +# manifests: the lockfile, workspace configuration, and every workspace package.json, at their +# repository paths. Dependency installation keys on these alone, so a source-only change reuses the +# installed layers. +# --------------------------------------------------------------------------------------------------- +FROM base AS manifests +WORKDIR /context +COPY . . +RUN set -eu; \ + mkdir /manifests; \ + cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \ + for manifest in apps/*/package.json builders/*/package.json core/package.json \ + modules/*/package.json packages/*/package.json surfaces/*/package.json \ + templates/*/package.json; do \ + if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \ + done + +# --------------------------------------------------------------------------------------------------- +# build: full install, validation, build, then the exact runtime file set. +# --------------------------------------------------------------------------------------------------- +FROM toolchain AS build +# Sources first, then one install: workspace packages publish `bin` commands from source files (the +# module UI builder's, which every module build invokes), and pnpm links a bin only when its target +# already exists. The store cache mount keeps a rebuild off the network all the same. +COPY . . +RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \ + pnpm install --frozen-lockfile --store-dir /pnpm/store +# The same gates `pnpm verify` runs for boundaries, modules, and migrations, against exactly the +# module set in this context. `pnpm boundaries` comes first and reads only sources: among its rules, +# a module that imports something at run time without declaring it a production dependency fails +# here, because the production-only install below would leave it unresolvable at API startup. Each +# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit. +RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build +# What the runtime resolves at startup, at the paths it resolves them from: +# - every workspace package's `dist/` (every package exports only from `dist/`), which includes +# the Dashboard and display host assets under `surfaces/shell/dist/browser/`; +# - Core's migrations, `core/drizzle/` with its `meta/` journal; +# - each module directory whole, minus `node_modules`, because each manifest names its own +# entrypoints, UI artifacts, and migrations directory. +# `node_modules` comes from the production-only install below, never from this stage. +RUN set -eu; \ + mkdir -p /runtime/core /runtime/modules; \ + for package in apps/* core packages/* surfaces/*; do \ + if [ -d "$package/dist" ]; then \ + mkdir -p "/runtime/$package"; \ + cp -a "$package/dist" "/runtime/$package/dist"; \ + fi; \ + done; \ + cp -a core/drizzle /runtime/core/drizzle; \ + if [ -d modules ]; then \ + tar -C modules --exclude='node_modules' -cf - . | tar -C /runtime/modules -xf -; \ + fi + +# --------------------------------------------------------------------------------------------------- +# prod-deps: the same lockfile, production dependencies only. No compiler, bundler, or test runner +# reaches the runtime image. +# --------------------------------------------------------------------------------------------------- +FROM toolchain AS prod-deps +COPY --from=manifests /manifests/ ./ +RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \ + pnpm install --frozen-lockfile --prod --store-dir /pnpm/store + +# --------------------------------------------------------------------------------------------------- +# runtime +# --------------------------------------------------------------------------------------------------- +FROM base AS runtime + +ARG VERSION="" +ARG REVISION="" +ARG SOURCE="" +LABEL org.opencontainers.image.title="Ekklesia" \ + org.opencontainers.image.description="Ekklesia API, Dashboard, display host, and operator CLIs" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.source="${SOURCE}" \ + org.opencontainers.image.base.name="docker.io/library/node:24.18.0-trixie-slim" \ + org.opencontainers.image.base.digest="sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573" + +# Only the container's own listen address differs from the platform default: outside a container +# the API still binds 127.0.0.1. `production` is stated rather than relied on as the default. +ENV NODE_ENV=production \ + EKKLESIA_API_HOST=0.0.0.0 \ + EKKLESIA_API_PORT=3000 + +# The repository layout, preserved: the API finds the repository root three directories above +# `apps/api/dist/`, and every CLI uses its working directory as the root. +WORKDIR /opt/ekklesia +COPY --from=prod-deps /opt/ekklesia/ ./ +COPY --from=build /runtime/ ./ + +# Files stay root-owned: the unprivileged runtime user can read the installation, never modify it. +USER node +EXPOSE 3000 + +# Readiness, not liveness: an instance whose database is unreachable or that is shutting down is +# unhealthy. Node's own fetch, so the image needs no curl. +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.EKKLESIA_API_PORT || '3000') + '/api/v1/health/ready', { signal: AbortSignal.timeout(4000) }).then((response) => process.exit(response.status === 200 ? 0 : 1), () => process.exit(1))"] + +# `pnpm api:start` without its build step and without `--env-file`: configuration comes only from +# the container environment. The same image runs every CLI: `node core/dist/cli/.js`. +CMD ["node", "apps/api/dist/server.js"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..48c21bd --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,217 @@ +# 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. + +## The production image + +[`Dockerfile`](Dockerfile) builds **one image** for two jobs: the long-running `api` service, and +every operator-run command (migrations, Doctor, initial-owner creation, module enablement). There +is no second image and no second build. + +### What it builds + +The build context is an **already-assembled workspace**: this repository, plus zero or more module +packages already present in `modules/`, plus a `pnpm-lock.yaml` describing exactly that set. +The Dockerfile clones nothing and chooses no modules. It builds what it is given. With no +`modules/` directory, it builds the platform alone; CI builds that case on every change. + +Assembling the context (checking out the platform at a release tag, each module at a pinned tag or +commit, and the instance's own lockfile) belongs to the installation's instance composition, not to +this file. + +| Stage | Does | +| --- | --- | +| `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. | +| `toolchain` | Enables pnpm through Corepack, at exactly the root `packageManager` version. | +| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and every workspace `package.json` at its repository path. | +| `build` | `pnpm install --frozen-lockfile`, then `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`, against exactly the modules in the context. Never uses host `node_modules`: `.dockerignore` excludes them. | +| `prod-deps` | The same lockfile with `--prod`: production dependencies only. | +| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000). No pnpm, no compiler, no curl. | + +The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform +finds its files: + +- the API takes the repository root as three directories above `apps/api/dist/`, and reads + `package.json`, `packages/module-sdk/package.json`, and `modules/` beneath it; +- every CLI takes its **working directory** as the root and also reads Core's migrations in + `core/drizzle/`, so commands must run from `/opt/ekklesia`, the image's `WORKDIR`; +- the Dashboard and display host files are read from `surfaces/shell/dist/browser/`; +- each module directory is copied whole, minus `node_modules`, because each manifest names its + own entrypoints, UI artifacts, and migrations directory. + +### Building + +```sh +docker build \ + --file deploy/Dockerfile \ + --build-arg VERSION=0.1.0 \ + --build-arg REVISION="$(git rev-parse HEAD)" \ + --build-arg SOURCE=https://git.example.org/your-org/ekklesia \ + --tag ekklesia:0.1.0 \ + . +``` + +The three build arguments become the OCI labels `org.opencontainers.image.version`, `.revision`, +and `.source`. They have no defaults: the repository cannot know an installation's Git host. The +image also records its base image name and digest. BuildKit is required (Docker's default builder); +set `DOCKER_BUILDKIT=1` if your Docker CLI has no buildx plugin. + +No secret is a build argument, and none enters any layer. `.env` files are excluded from the build +context entirely, and no registry credentials are needed. + +**A module missing a production dependency fails the build, not the API.** The runtime receives +production dependencies only, so a module that imports a package at run time but declares it only +as a development or peer dependency would build and then fail at startup. `pnpm boundaries` runs +in the build stage and rejects that module first +(`runtime-imports-are-production-dependencies`, see +[Repository boundaries](../docs/architecture/repository-boundaries.md)). The same applies to every +other boundary rule: a module the platform's own CI would reject cannot be built into an image. + +**A platform-only image does not exercise native module dependencies.** With no modules in the +context, nothing like sharp is installed, so the CI image build proves nothing about them. An +instance build that includes such a module must smoke-test the built image itself: start it, +confirm `/api/v1/health/ready` answers `200`, and exercise the module's native code path (for +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: + +| Variable | Image value | Why | +| --- | --- | --- | +| `NODE_ENV` | `production` | Stated rather than relied on. Anything other than `development` already validates as production. | +| `EKKLESIA_API_HOST` | `0.0.0.0` | Reachable from other containers. Outside a container the platform default stays `127.0.0.1`. | +| `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. + +Never publish the API's port to the host or the Internet. Only the proxy publishes ports. + +### The `api` service + +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. + +```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. + +### Health + +The image's `HEALTHCHECK` calls `GET /api/v1/health/ready` on `127.0.0.1` with Node's built-in +`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. + +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. + +### Operator commands + +The same image runs every CLI, directly with `node core/dist/cli/.js`. 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. + +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: + +```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 owner@example.org` | `docker run --rm -it --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 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 ` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/set-module-enabled.js --disable --module ` | + +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. + +### Size + +The platform-only image is about 378 MB: the Debian slim Node base (about 230 MB), production +dependencies (about 141 MB), and built outputs (about 6 MB). A rebuild that reuses a warm pnpm store +cache can come out about 21 MB larger. When esbuild's install script runs, it hard-links its +`bin/esbuild` to the native binary it ships beside it; when pnpm restores esbuild from the store's +cached post-install state instead, that file is a second full copy. Both are esbuild, one of the +unused tools below. + +About 57 MB of the production dependencies are development tools that no runtime code imports: +`vitest`, `vite`, `drizzle-kit`, `tsx`, `esbuild`, and their native bindings. They arrive as +optional peer dependencies of Better Auth (and `esbuild` through the module UI builder), which pnpm +resolves because the workspace already has them. Excluding them means changing dependency +resolution and the lockfile, which is separate work. diff --git a/docs/development/security.md b/docs/development/security.md index b75cdc2..ac3d779 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -464,9 +464,11 @@ Until that exists, the trusted-code model above is the security model. ## Current deployment state As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse -proxy, no public listener, and no deployment automation of any kind in this repository — no deploy -script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever -been committed; only `.env.example`. +proxy, no public listener, and no deployment automation in this repository — no deploy script, no +CI deployment step, no container publish, no orchestration manifest. The repository can **build** a +production image ([`deploy/`](../../deploy/README.md)), and CI builds it without modules on every +change, but nothing pushes, publishes, or runs it anywhere. No `.env` has ever been committed; only +`.env.example`, which the image build context excludes. Two production defaults are relevant but are **not** by themselves a deployment guarantee: