# Ekklesia's one generic platform image (ADR 0044, ADR 0048): the `api` service and every operator-run # CLI, from one build, for every installation. See deploy/README.md for how to build and run it. # # The build context is this repository. The image contains **zero feature modules**: installed # modules live in installation-owned storage, mounted read-only at /var/lib/ekklesia/modules # (EKKLESIA_MODULES_DIR) and discovered when a process starts. `.dockerignore` keeps a checkout's # `modules/` out of the context, so a build is the same whether or not one is present. # # 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 the package.json of every platform package # the runtime needs, at their repository paths. Dependency installation keys on these alone, so a # source-only change reuses the installed layers. The module template is a workspace package but not # part of the platform, so neither its manifest nor its dependencies reach the runtime. # --------------------------------------------------------------------------------------------------- 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 core/package.json packages/*/package.json \ surfaces/*/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 . . # A second line of defence behind `.dockerignore`: a context that still carries installed modules is # refused rather than built into a platform image. RUN if [ -e modules ]; then \ echo "The build context contains modules/. Installed modules never enter the platform image (ADR 0048)." >&2; \ exit 1; \ fi 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. With no modules in the # context, module validation covers the empty set and migration validation covers Core's own. 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 platform 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. # Never a module and never the module template. `node_modules` comes from the production-only # install below, never from this stage. RUN set -eu; \ mkdir -p /runtime/core; \ 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 # --------------------------------------------------------------------------------------------------- # prod-deps: the same lockfile, production dependencies only, for the platform packages alone. No # compiler, bundler, or test runner reaches the runtime image, and neither does anything only the # module template depends on. # --------------------------------------------------------------------------------------------------- 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 \ EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules # 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. Installed modules are not # under it: they are read from EKKLESIA_MODULES_DIR. WORKDIR /opt/ekklesia COPY --from=prod-deps /opt/ekklesia/ ./ COPY --from=build /runtime/ ./ # The mount point for installation-owned module storage (ADR 0048), empty in the image. An image run # with nothing mounted there has zero modules; the reference deployment mounts host storage # read-only over it. RUN install -d -o root -g root -m 0755 /var/lib/ekklesia/modules # 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"]