The monorepo was on npm workspaces. pnpm gives it a content-addressed store shared between the eight packages, a lockfile that records the whole graph rather than a flattened view of it, and — the reason this mattered in practice — `workspace:*`, which makes an internal dependency unambiguous instead of a version range that npm may satisfy from the registry. Mechanics: - `packageManager: pnpm@11.21.0` pins the version; corepack installs it in CI and in the image, so all three environments resolve identically. - The npm `workspaces` array is replaced by `pnpm-workspace.yaml`. pnpm ignores the former, and keeping both would leave two sources of truth. - All six internal dependencies moved to `workspace:*`. - Root scripts use `pnpm -r --if-present` and `pnpm -F <pkg>`. Two findings worth recording, both from running it rather than reading it: `tsx` was a devDependency, but the server runs TypeScript directly in production — the container's command is `pnpm exec tsx apps/api/src/server.ts`. Under npm this was concealed by the runtime stage re-installing tsx by hand after pruning dev dependencies. Under `pnpm install --prod` that sleight of hand stops working and the image simply fails to start. tsx is now declared in `dependencies`, which is what it has always actually been. The first image build failed with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY. That is not a pnpm bug: it had decided the modules directory was stale and wanted confirmation before deleting it, which a non-interactive build cannot give. The trigger was the host's `node_modules` reaching the build context — there was no `.dockerignore` at all. pnpm's tree is symlinks into a content-addressed store, so copying it into an image produces dangling links and a directory pnpm rightly considers corrupt. Fixed by adding `.dockerignore` and setting `CI=true`, which is required in any non-interactive pnpm build. `esbuild` is denied install scripts via `allowBuilds`. Its platform binary arrives through the optional dependency `@esbuild/linux-x64` and the postinstall only verifies it; confirmed by running the binary directly, which reports 0.25.12. Verified under pnpm: typecheck clean, 150 tests / 0 failures, e2e passes, web builds. The image was built and booted against a real Postgres — health ok, `/api/dashboard` 401 with an issuer configured, `/` and `/capacity` serve the SPA, `/og.png` serves as image/png, and the migrator runs from the pruned runtime stage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+38
-17
@@ -9,51 +9,72 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Manifests first, so a dependency install is cached across source-only edits.
|
||||
COPY package.json package-lock.json* ./
|
||||
# Corepack installs the exact pnpm pinned by `packageManager`, so the image
|
||||
# builds with the same version as CI and as a developer's laptop.
|
||||
#
|
||||
# Both variables are load-bearing in a container build, and neither is
|
||||
# optional:
|
||||
# - the download prompt cannot be answered by a non-interactive build;
|
||||
# - CI=true is what stops pnpm asking for confirmation before it touches a
|
||||
# modules directory it considers stale. Without it the build fails with
|
||||
# ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY, which reads like a bug but is
|
||||
# pnpm correctly refusing to delete files nobody confirmed.
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV CI=true
|
||||
RUN corepack enable
|
||||
|
||||
# Manifests and the lockfile first, so a dependency install is cached across
|
||||
# source-only edits. pnpm needs every workspace manifest present to resolve the
|
||||
# graph, hence the file-by-file copy rather than `COPY . .`.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY packages/core/package.json packages/core/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/prime/package.json packages/prime/
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/mcp/package.json apps/mcp/
|
||||
COPY apps/cli/package.json apps/cli/
|
||||
COPY apps/piggy/package.json apps/piggy/
|
||||
RUN npm install --no-audit --no-fund
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
# Typecheck as a build gate. A deploy that does not compile should fail here,
|
||||
# loudly, rather than at runtime in front of a user.
|
||||
RUN npx tsc --noEmit -p packages/core/tsconfig.json \
|
||||
&& npx tsc --noEmit -p packages/db/tsconfig.json \
|
||||
&& npx tsc --noEmit -p packages/prime/tsconfig.json \
|
||||
&& npx tsc --noEmit -p apps/api/tsconfig.json \
|
||||
&& npx tsc --noEmit -p apps/web/tsconfig.json \
|
||||
&& npx tsc --noEmit -p apps/mcp/tsconfig.json \
|
||||
&& npx tsc --noEmit -p apps/piggy/tsconfig.json
|
||||
RUN pnpm run typecheck
|
||||
|
||||
RUN npm run build -w @pig/web
|
||||
RUN pnpm -F @pig/web run build
|
||||
|
||||
# ---------------------------------------------------------------- runtime
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV CI=true
|
||||
RUN corepack enable
|
||||
|
||||
# Reinstall without dev dependencies. tsx is needed at runtime because the
|
||||
# server runs TypeScript directly; everything else is production-only.
|
||||
COPY package.json package-lock.json* ./
|
||||
# Install production dependencies only. The server runs TypeScript directly, so
|
||||
# tsx is declared in `dependencies` rather than `devDependencies` — it is
|
||||
# genuinely needed at runtime, and pretending otherwise meant the old image had
|
||||
# to reinstall it by hand after pruning.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY packages/core/package.json packages/core/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/prime/package.json packages/prime/
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/mcp/package.json apps/mcp/
|
||||
COPY apps/cli/package.json apps/cli/
|
||||
COPY apps/piggy/package.json apps/piggy/
|
||||
RUN npm install --omit=dev --no-audit --no-fund && npm install tsx --no-audit --no-fund
|
||||
# apps/web is a build-time workspace only; its manifest is still required for
|
||||
# the lockfile to resolve, but none of its dependencies are installed here.
|
||||
COPY apps/web/package.json apps/web/
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
COPY packages ./packages
|
||||
COPY apps/api ./apps/api
|
||||
COPY apps/mcp ./apps/mcp
|
||||
COPY apps/cli ./apps/cli
|
||||
COPY apps/piggy ./apps/piggy
|
||||
COPY --from=build /app/apps/web/dist ./apps/web/dist
|
||||
|
||||
@@ -63,9 +84,9 @@ USER node
|
||||
|
||||
EXPOSE 8920
|
||||
|
||||
# The health endpoint is unauthenticated by design so this works without
|
||||
# The health endpoint is unauthenticated by design, so this works without
|
||||
# credentials baked into the image.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:8920/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["npx", "tsx", "apps/api/src/server.ts"]
|
||||
CMD ["pnpm", "exec", "tsx", "apps/api/src/server.ts"]
|
||||
|
||||
Reference in New Issue
Block a user