From 857856fe4e318a67295fe0556e6daeab86bb2c59 Mon Sep 17 00:00:00 2001 From: Kevin Riehl Date: Wed, 6 May 2026 16:26:15 -0700 Subject: [PATCH] Slim the backend image: pnpm deploy + drop bundled npm CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tehriehlbudget-backend/Dockerfile | 53 ++++++++++++++++--------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/tehriehlbudget-backend/Dockerfile b/tehriehlbudget-backend/Dockerfile index 167e045..bea005e 100644 --- a/tehriehlbudget-backend/Dockerfile +++ b/tehriehlbudget-backend/Dockerfile @@ -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/@/ -# 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//. +# 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", "--"]