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 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 readspackage.json,packages/module-sdk/package.json, andmodules/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'sWORKDIR; - 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 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). 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. 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
healthyand served/api/v1/health/ready,GET /, and the Dashboard script; plan-migrations.js,apply-migrations.js, anddoctor.jsran under the same flags;- nothing wrote to
/tmpin 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 runallocates a terminal by default. With plaindocker 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-iand no-t.- Run
--rmcommands on the same Docker network aspostgres, soDATABASE_URLresolves; with Compose that is automatic. - Add the same read-only flags to plain
docker runcommands 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.