Feat/deploy container image #48

Merged
Zayin_Admin merged 3 commits from feat/deploy-container-image into main 2026-10-02 01:16:21 +00:00
5 changed files with 396 additions and 3 deletions
Showing only changes of commit 756e9b821c - Show all commits
+25
View File
@@ -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
+32
View File
@@ -103,3 +103,35 @@ jobs:
- name: Exercise transactional migrations against PostgreSQL - name: Exercise transactional migrations against PostgreSQL
run: pnpm test:migrations:integration 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
+129
View File
@@ -0,0 +1,129 @@
# 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/<id>`, 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@<exact version>) 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 modules and migrations, against exactly the module set in this
# context. Each script builds first; the final build is a Turbo cache hit that keeps the intent
# explicit.
RUN 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/<command>.js`.
CMD ["node", "apps/api/dist/server.js"]
+205
View File
@@ -0,0 +1,205 @@
# 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 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 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.
+5 -3
View File
@@ -464,9 +464,11 @@ Until that exists, the trusted-code model above is the security model.
## Current deployment state ## Current deployment state
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse 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 proxy, no public listener, and no deployment automation in this repository — no deploy script, no
script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever CI deployment step, no container publish, no orchestration manifest. The repository can **build** a
been committed; only `.env.example`. 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: Two production defaults are relevant but are **not** by themselves a deployment guarantee: