A module that imports a package at run time without declaring it a production dependency now fails the image build instead of the API's startup, since the runtime receives production dependencies only. Co-Authored-By: Claude Opus 5.5 <[email protected]>
214 lines
11 KiB
Markdown
214 lines
11 KiB
Markdown
# 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/<id>`, 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/<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.
|
|
|
|
**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 [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.
|
|
|
|
### 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).
|
|
|
|
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.
|