# SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 # One image, three processes (spec §13). Node 24 LTS, nothing but the app in the final stage, and # a non-root user that owns nothing it runs. # # The `@conusai-kit` and `@conusai-sdk` scopes are served by the ConusAI vendor registry. Its URL # and token arrive as a BuildKit secret the install stage copies to the user-level ~/.npmrc, the # only config pnpm trusts for credentials, and never reach a layer # (docs/how-to/private-package-registries.md). FROM node:24-slim AS deps ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ PNPM_HOME=/pnpm \ CI=1 WORKDIR /app # The manifests alone, so a source change does not re-resolve the dependency graph. COPY package.json pnpm-workspace.yaml .npmrc pnpm-lock.yaml ./ COPY config/package.json ./config/ COPY modules/package.json ./modules/ COPY apps/web/package.json ./apps/web/ COPY apps/worker/package.json ./apps/worker/ RUN corepack enable pnpm # A customer repository commits pnpm-lock.yaml (decision AT-D6), and the install is frozen # against it: an image is built from exactly the dependency graph the gate ran. # # The credential arrives under one secret id per build path: plain `npmrc` for a preset # single-image build, `conusai_npmrc` for a compose build (the platform prefixes its compose # secrets so a tenant's own can never collide). Mount both and take whichever was delivered — # a build that asked for only one id got an empty mount on the other path, and pnpm fell back # to the public registry where the vendor scopes 404 (stage, deployment 4, 2026-10-20). # # The trap removes the copied credential before the shell exits: a file left behind would be # committed to the cached deps layer, token and all. RUN --mount=type=secret,id=conusai_npmrc,target=/run/secrets/conusai_npmrc,required=false \ --mount=type=secret,id=npmrc,target=/run/secrets/npmrc,required=false \ trap 'rm -f /root/.npmrc' EXIT; \ if [ -s /run/secrets/conusai_npmrc ]; then \ cp /run/secrets/conusai_npmrc /root/.npmrc; \ elif [ -s /run/secrets/npmrc ]; then \ cp /run/secrets/npmrc /root/.npmrc; \ fi; \ pnpm install --frozen-lockfile --ignore-scripts FROM deps AS build WORKDIR /app COPY . . # config and apps/worker emit JavaScript; apps/web builds the Next.js shell. pnpm runs them in # dependency order, so config is built before the two that import it. RUN pnpm -r run build # The anchor catalogue (`data-guide` and `guideAnchor` literals), then the code-guides bundle # kit:guides # conusai.json declares as guides.bundle: public content the platform reads out of this image # kit:guides # and publishes after a healthy deploy (ADR-045); no publication credential is ever in it. # kit:guides RUN pnpm kit guides:anchors --out guides-anchors.json # kit:guides RUN node apps/worker/dist/guides-bundle.js /app/.conusai/guides-bundle.json guides-anchors.json # kit:guides # The image keeps what it runs and nothing else: the formatter, the type-checker and the test # runner are build-time tools. The workspace links the three processes resolve stay, because they # are dependencies and not devDependencies. RUN pnpm prune --prod --ignore-scripts FROM node:24-slim AS runtime ENV NODE_ENV=production \ COREPACK_ENABLE_DOWNLOAD_PROMPT=0 WORKDIR /app # node:24-slim ships a `node` user (uid 1000). The app owns none of the files it runs, so a # process that is compromised cannot rewrite its own code. # # The next step for size, once a customer's dependency graph has settled, is Next.js's # `output: "standalone"` for the web process plus `pnpm deploy --prod` for the worker, which # leaves the build's own toolchain (the compiler binaries) out of the image entirely. COPY --from=build --chown=root:root /app /app # Git checkouts created under the service's private umask can carry 0700 directories # and 0600 files into the build. COPY preserves those modes: root can build them, # but node cannot even resolve Next through an unsearchable parent (KI-210). # Keep code root-owned and deny group/other writes while allowing runtime reads # and directory traversal. X preserves executable files without making data executable. RUN chmod -R a+rX,go-w /app USER node # Every process is started by compose.yaml with its own command; there is no default, because a # container that silently starts the wrong one is worse than one that does not start. CMD ["node", "-e", "console.error('start this image with one of the commands in compose.yaml: web, worker or migrate'); process.exit(64)"]