From 6098ffb8e5cda536e2d8ee36229381dbddaf0ccc Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 6 Sep 2026 00:42:19 -0700 Subject: [PATCH] Ship the runtime image without the build tree 639 MB unpacked and 119 MB compressed, against 239 MB and 59 MB now. Two causes, both in the runtime stage. The build stage's node_modules was copied across whole: 132 MB of vite, TypeScript, esbuild, jsdom and React that the server never loads, since it needs hono and its Node adapter and nothing else -- about 4 MB. The runtime stage now installs the server workspace's production dependencies on its own. Then `chown -R node:node /data /app` rewrote every one of those files, which on overlayfs copies the whole tree into a second layer of the same size. Only /data is written to at runtime; /app stays root-owned and read-only to the process, which is what an immutable container wants anyway. The base image's npm, npx, yarn and corepack are removed from the runtime stage as well. The server is started with `node` directly and never calls them; anyone who gains code execution should not find a package manager waiting. Checked that the image starts --read-only, serves the gzipped bundle, signs in against Stalwart, holds a push stream, and that `hono` loads from the 3.1 MB that remains. --- Dockerfile | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e0c8fab..323f41b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,16 +37,28 @@ ENV NODE_ENV=production \ IHASMAIL_VERSION=$IHASMAIL_VERSION \ BASE_PATH=$BASE_PATH WORKDIR /app -COPY package.json ./ +COPY package.json package-lock.json* ./ COPY server/package.json server/ # config.ts reads the version through this at startup. With IHASMAIL_VERSION # set it never looks further; without it, it falls back to package.json rather # than failing, since there is no git in here to ask. COPY scripts/ ./scripts/ -COPY --from=build /app/node_modules ./node_modules +# Only what the server loads at runtime: hono and its Node adapter, about 4 MB. +# The build stage's tree is 132 MB of vite, TypeScript, esbuild and React that +# never executes here but shipped anyway -- and showed up in every CVE scan. +RUN npm ci --ignore-scripts --omit=dev --workspace server \ + && rm -rf /root/.npm /tmp/* COPY --from=build /app/server/dist ./server/dist COPY --from=build /app/web/dist ./web/dist -RUN mkdir -p /data && chown -R node:node /data /app +# /data is the only path the process may write. /app stays root-owned and +# read-only to the runtime user on purpose; the previous `chown -R /app` +# re-wrote every file and, on overlayfs, duplicated the whole tree into a +# second 173 MB layer. +RUN mkdir -p /data && chown node:node /data \ + # The base image ships a package manager the server never calls. Anyone who + # gets code execution should not find one waiting for them. + && rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \ + /usr/local/bin/corepack /opt/yarn* /usr/local/bin/yarn /usr/local/bin/yarnpkg USER node # No `VOLUME ["/data"]`. It reads like documentation for where the session file # goes, but Docker acts on it: a container started without `-v` gets an