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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef1383d080
commit
756e9b821c
@@ -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"]
|
||||
Reference in New Issue
Block a user