Files
Ekklesia/deploy
ekklesiaandClaude Opus 5.5 756e9b821c feat(deploy): add the production container image
Add deploy/Dockerfile and .dockerignore (ADR 0044): one image for the
api service and every operator-run CLI, built from an already-assembled
workspace (the platform plus whatever modules/<id> the context holds).

- node:24.18.0-trixie-slim pinned by index digest; pnpm via Corepack at
  the root packageManager version.
- Build stage: frozen install, modules:validate, migrations:validate,
  build. Runtime: production-only node_modules plus every dist/, core
  migrations, and each module directory, in the repository layout the
  API and CLIs resolve paths from; root-owned, run as node.
- NODE_ENV=production and EKKLESIA_API_HOST=0.0.0.0 inside the image
  only; the platform default stays 127.0.0.1. Configuration comes only
  from the environment; no .env enters the context or any layer.
- HEALTHCHECK on /api/v1/health/ready with Node's fetch; OCI labels for
  source, revision, and version.
- CI builds the image without modules: build only, no credentials, no
  push.
- deploy/README.md documents the image, the read-only production
  posture (verified), init: true, and the exact CLI commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 20:00:43 -05:00
..

Deployment

This directory holds Ekklesia's reference self-hosted deployment (ADR 0044). 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 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 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

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 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. 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.

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. 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:

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). 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.