Slim the backend image: pnpm deploy + drop bundled npm CLI
CI / test (push) Successful in 23s
CI / lint (push) Successful in 27s
CI / secrets-scan (push) Successful in 4s
CI / vuln-scan (push) Successful in 13s
CI / sast (push) Successful in 9s
CI / build-images (push) Successful in 1m46s
CI / image-scan (push) Failing after 43s
CI / push (push) Has been skipped

Trivy flagged 12 HIGH/CRITICAL CVEs on the backend image. 11 came from
/usr/local/lib/node_modules/npm — the npm CLI bundled with node:alpine,
which we never invoke (corepack/pnpm at build, `node dist/main` at
runtime). Delete it from the runtime stage; that alone clears 11
findings (cross-spawn RegEx DoS, multiple node-tar arbitrary-write
CVEs, minimatch DoS, glob command injection).

The 12th was serialize-javascript@6.0.2, pulled in via
@rollup/plugin-terser (used by vite-plugin-pwa on the frontend). It was
landing in the backend image because the previous Dockerfile relied on
`pnpm install --filter backend... --prod` over a hoisted workspace,
which still installs every workspace package's transitive deps in the
shared root node_modules. The runtime image was shipping vite, vitest,
react, the whole NestJS CLI — none of which it needs.

Switch to `pnpm deploy --prod --filter tehriehlbudget-backend /deploy`,
which produces a self-contained, prod-only, hoisted bundle for just the
backend. Copy the generated Prisma client into the deploy explicitly
since .prisma/client/ is a co-located output directory, not a package,
and pnpm deploy doesn't always carry it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 16:26:15 -07:00
parent 6f626d0c22
commit 857856fe4e
+28 -25
View File
@@ -1,48 +1,51 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS deps
FROM node:${NODE_VERSION}-alpine AS builder
RUN apk add --no-cache libc6-compat openssl
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /repo
# Force a hoisted (flat) node_modules layout for the image. The default pnpm
# isolated layout puts dependencies inside node_modules/.pnpm/<pkg>@<ver>/
# which makes `prisma generate`'s output (node_modules/.prisma/client/) live
# in a path that doesn't survive a multi-stage COPY. Hoisted gives us
# predictable /repo/node_modules/{@prisma,.prisma}/ paths. Local dev is
# unaffected — this .npmrc only exists inside the build context.
# Hoisted layout so `prisma generate` writes to predictable paths
# (/repo/node_modules/{@prisma,.prisma}/) instead of into .pnpm/<hash>/.
# Local dev keeps the isolated layout — this .npmrc only exists in the
# build context.
RUN echo "node-linker=hoisted" > /repo/.npmrc
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY tehriehlbudget-backend/package.json tehriehlbudget-backend/
COPY tehriehlbudget-backend/ tehriehlbudget-backend/
COPY tehriehlbudget-frontend/package.json tehriehlbudget-frontend/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter tehriehlbudget-backend...
FROM deps AS build
WORKDIR /repo
COPY tehriehlbudget-backend/ tehriehlbudget-backend/
RUN pnpm --filter tehriehlbudget-backend exec prisma generate
RUN pnpm --filter tehriehlbudget-backend run build
FROM deps AS prod-deps
WORKDIR /repo
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --prod --filter tehriehlbudget-backend...
# pnpm deploy produces a self-contained prod-only bundle for the backend:
# only the dependencies the runtime actually needs, no frontend tooling,
# no devDeps. Drops vite, vitest, react, the whole NestJS CLI etc. that
# were leaking into the runtime via the workspace's hoisted node_modules.
RUN pnpm --filter tehriehlbudget-backend --prod deploy /deploy
# Overlay the generated Prisma client. pnpm deploy copies the @prisma/client
# package as-is, but the co-located .prisma/client/ output directory isn't
# a package so it isn't always carried along. Copy it explicitly to be safe.
RUN cp -r /repo/node_modules/.prisma /deploy/node_modules/.prisma
FROM node:${NODE_VERSION}-alpine AS runtime
RUN apk add --no-cache libc6-compat openssl tini
RUN apk add --no-cache libc6-compat openssl tini \
# Remove the npm CLI that ships with node:alpine. We use pnpm via
# corepack at build time and `node dist/main` at runtime — npm is
# never invoked. Its bundled deps (cross-spawn, glob, minimatch,
# tar) are the source of 11 of the 12 HIGH/CRITICAL CVEs Trivy flags
# in the image, and they're all in code paths we never execute.
&& rm -rf /usr/local/lib/node_modules/npm \
/usr/local/bin/npm \
/usr/local/bin/npx
WORKDIR /app
RUN addgroup -S nodeapp && adduser -S nodeapp -G nodeapp
ENV NODE_ENV=production
COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/dist ./dist
COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/prisma ./prisma
COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/package.json ./package.json
# With hoisted linker, all deps live at the workspace-root node_modules.
COPY --from=prod-deps --chown=nodeapp:nodeapp /repo/node_modules ./node_modules
# Overlay the generated Prisma client from the build stage. The prod-deps
# stage doesn't have the `prisma` CLI (devDep) so it can't generate.
COPY --from=build --chown=nodeapp:nodeapp /repo/node_modules/.prisma ./node_modules/.prisma
COPY --from=build --chown=nodeapp:nodeapp /repo/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder --chown=nodeapp:nodeapp /deploy/ ./
USER nodeapp
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]