# DOMINUS — Multi-stage production build # Requires Docker BuildKit (default since Docker 23.0). # # Build targets: # docker build --target api -t dominus-api:latest . # docker build --target worker -t dominus-worker:latest . # docker build --target scheduler -t dominus-scheduler:latest . # # Run: # docker run -d -p 3000:3000 -v ./data:/app/data dominus-api # docker run -d -v ./data:/app/data dominus-worker # docker run -d -v ./data:/app/data dominus-scheduler # Immutable base image (ADR-0046): pinned by digest so a retagged # node:22-alpine cannot silently inject new CVEs into the build. Bump the # digest deliberately along with an upstream release with: # docker buildx imagetools inspect node:22-alpine --format '{{.Manifest.Digest}}' ARG NODE_IMAGE=node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 # ---- Stage 1: Install production dependencies ---- FROM ${NODE_IMAGE} AS deps WORKDIR /app # .npmrc carries the resolution rules of the repository (legacy-peer-deps, # for the peer conflicts between @typescript-eslint/* and TypeScript 6.x). # It has to be inside the build context: without it `npm ci` in the image # resolves peers strictly while the lockfile was written under the # repository rules, and the two disagree. The symptom is an image build # that fails with "Invalid: lock file's X does not satisfy X" on a # dependency bump that the checkout installs without complaint. COPY package.json package-lock.json .npmrc ./ # --ignore-scripts keeps arbitrary install scripts from running during the # build (supply-chain hygiene). better-sqlite3 is the deliberate exception: # its native binding is produced by its install script (prebuild-install), # which --ignore-scripts skips — without an explicit rebuild the runtime # images would crash on require('better-sqlite3'). The toolchain is a safety # net for the node-gyp fallback when no prebuilt binary matches the target; # it lives only in this stage and never reaches the runtime images (only # node_modules is copied out of it). RUN npm ci --omit=dev --ignore-scripts \ && apk add --no-cache python3 make g++ \ && npm rebuild better-sqlite3 # ---- Stage 2: Backend Build ---- FROM ${NODE_IMAGE} AS backend-build WORKDIR /app COPY package.json package-lock.json tsconfig.json .npmrc ./ COPY src/ src/ # finalize-dist.mjs runs as part of `npm run build` (stages the SPA into # dist/web and prunes compiled tests), so it must exist in the build stage. COPY scripts/ scripts/ RUN npm ci && npm run build # ---- Stage 3: Frontend Build (API only) ---- FROM ${NODE_IMAGE} AS frontend-build WORKDIR /app COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ RUN npm run build # ---- Stage 4: API Server (Express + SPA) ---- FROM ${NODE_IMAGE} AS api WORKDIR /app # Alpine v3.24's own package repo already carries the fix for 10 libssl3/ # libcrypto3 CVEs (incl. CVE-2026-14456, HIGH) that shipped baked into # NODE_IMAGE's libssl3-3.5.7-r0. Require at least the patched release via a # version floor (not an exact pin): exact pins rot when Alpine drops the old # minor from the repo (3.5.8-r0 broke the build once 3.5.9-r0 landed) while a # floor keeps the CVE guarantee and survives repo updates (ADR-0046) — # drop this once a NODE_IMAGE digest bump picks up libssl3 >= 3.5.8-r0 baked in. # zlib >= 1.3.2-r1 fixes CVE-2026-85091 (Trivy, medium) in the same way; drop it with the # next NODE_IMAGE digest bump that ships it. RUN apk add --no-cache --upgrade 'libssl3>=3.5.8-r0' 'libcrypto3>=3.5.8-r0' 'zlib>=1.3.2-r1' # Runtime strip (ADR-0046): the base image bundles the npm CLI with its own # dependency tree (/usr/local/lib/node_modules/npm). The runtime never # invokes npm — entrypoint and healthchecks are plain `node` — so the # bundled tree is pure attack surface. Removing it eliminates the entire # class of bundled-npm CVEs (e.g. ip-address, brace-expansion) from the # shipped image. Build stages keep npm: they run `npm ci`/`npm run build`. RUN rm -rf /usr/local/lib/node_modules/npm \ /usr/local/lib/node_modules/corepack \ /usr/local/bin/npm \ /usr/local/bin/npx \ /usr/local/bin/corepack RUN addgroup -S dominus && adduser -S dominus -G dominus COPY --from=deps --chown=dominus:dominus /app/node_modules node_modules/ COPY --from=backend-build --chown=dominus:dominus /app/dist dist/ COPY --from=frontend-build --chown=dominus:dominus /app/dist dist/web/ # CSS for the server-rendered public pages (/public/s/:slug, compare pages). # The server serves ./public/static relative to the working directory. COPY --chown=dominus:dominus public/ public/ COPY --chown=dominus:dominus package.json ./ COPY --chown=dominus:dominus LICENSE THIRD-PARTY-NOTICES.md /licenses/ # Database mount point owned by the runtime user, so a fresh bind/volume # mount at /app/data is writable (docker run -v ./data:/app/data ...). RUN mkdir -p /app/data && chown dominus:dominus /app/data LABEL org.opencontainers.image.title="dominus-api" \ org.opencontainers.image.licenses="AGPL-3.0-only" \ org.opencontainers.image.source="https://github.com/AlessioBrillo/dominus" USER dominus EXPOSE 3000 # Reachable from outside the container. Safe: the server refuses to start on # 0.0.0.0 unless API authentication is configured (API_KEYS / AUTH_PROVIDER). ENV NODE_ENV=production \ HOST=0.0.0.0 \ DATABASE_PATH=/app/data/dominus.db \ FRONTEND_DIST_PATH=./dist/web \ WORKER_ENABLED=false \ SCHEDULER_ENABLED=false HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD node dist/app/healthcheck-cli.js ENTRYPOINT ["node"] CMD ["dist/index.js"] # ---- Stage 5: Job Worker (no HTTP listener) ---- FROM ${NODE_IMAGE} AS worker WORKDIR /app # Patched libssl3/libcrypto3 floor — see the comment in the api stage. RUN apk add --no-cache --upgrade 'libssl3>=3.5.8-r0' 'libcrypto3>=3.5.8-r0' 'zlib>=1.3.2-r1' # Runtime strip (ADR-0046): npm/corepack are unused at runtime — see the # comment in the api stage. Removes the bundled-npm CVE surface class from # the shipped image. RUN rm -rf /usr/local/lib/node_modules/npm \ /usr/local/lib/node_modules/corepack \ /usr/local/bin/npm \ /usr/local/bin/npx \ /usr/local/bin/corepack RUN addgroup -S dominus && adduser -S dominus -G dominus COPY --from=deps --chown=dominus:dominus /app/node_modules node_modules/ COPY --from=backend-build --chown=dominus:dominus /app/dist dist/ COPY --chown=dominus:dominus package.json ./ COPY --chown=dominus:dominus LICENSE THIRD-PARTY-NOTICES.md /licenses/ # Backup mount point: pre-created with the runtime user's ownership so a # fresh named volume (`docker compose ... up -d`) inherits dominus:dominus # on first mount and BACKUP_DIR stays writable for the non-root user. RUN mkdir -p /backups /app/data && chown dominus:dominus /backups /app/data VOLUME ["/backups"] LABEL org.opencontainers.image.title="dominus-worker" \ org.opencontainers.image.licenses="AGPL-3.0-only" \ org.opencontainers.image.source="https://github.com/AlessioBrillo/dominus" USER dominus ENV NODE_ENV=production \ DATABASE_PATH=/app/data/dominus.db \ WORKER_ENABLED=true \ SCHEDULER_ENABLED=false \ PORT=3000 # Internal healthcheck HTTP listener on loopback (9090) — not exposed # # PORT is set to a valid value even though this process never binds it: # the configuration validates PORT (>= 1) because the API binds it, and # the validation runs for every process. The previous PORT=0 made this # image crash at boot with "PORT: Too small" — the worker and the # scheduler never started, in any deployment that did not override PORT # (the community compose stack), and the failure was invisible because # the API kept serving. The value is inert here: the health server binds # 127.0.0.1:9090 and nothing else. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:9090/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ENTRYPOINT ["node"] CMD ["dist/worker-entrypoint.js"] # ---- Stage 6: Scheduler (lightweight cron container) ---- FROM ${NODE_IMAGE} AS scheduler WORKDIR /app # Patched libssl3/libcrypto3 floor — see the comment in the api stage. RUN apk add --no-cache --upgrade 'libssl3>=3.5.8-r0' 'libcrypto3>=3.5.8-r0' 'zlib>=1.3.2-r1' # Runtime strip (ADR-0046): npm/corepack/npx are never used at runtime — # see the comment in the api stage. RUN rm -rf /usr/local/lib/node_modules/npm \ /usr/local/lib/node_modules/corepack \ /usr/local/bin/npm \ /usr/local/bin/npx \ /usr/local/bin/corepack RUN addgroup -S dominus && adduser -S dominus -G dominus COPY --from=deps --chown=dominus:dominus /app/node_modules node_modules/ COPY --from=backend-build --chown=dominus:dominus /app/dist dist/ COPY --chown=dominus:dominus package.json ./ COPY --chown=dominus:dominus LICENSE THIRD-PARTY-NOTICES.md /licenses/ # Backup mount point: pre-created with the runtime user's ownership so a # fresh named volume (`docker compose ... up -d`) inherits dominus:dominus # on first mount and BACKUP_DIR stays writable for the non-root user. RUN mkdir -p /backups /app/data && chown dominus:dominus /backups /app/data VOLUME ["/backups"] LABEL org.opencontainers.image.title="dominus-scheduler" \ org.opencontainers.image.licenses="AGPL-3.0-only" \ org.opencontainers.image.source="https://github.com/AlessioBrillo/dominus" USER dominus ENV NODE_ENV=production \ DATABASE_PATH=/app/data/dominus.db \ WORKER_ENABLED=false \ SCHEDULER_ENABLED=true \ PORT=3000 # Internal healthcheck HTTP listener on loopback (9091) — not exposed # # PORT: same as the worker stage — a valid value for the shared # configuration validation, never bound by this process. HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:9091/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ENTRYPOINT ["node"] CMD ["dist/scheduler-entrypoint.js"]