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.
This commit is contained in:
2026-09-06 00:42:19 -07:00
parent 01f721d8d1
commit 6098ffb8e5
+15 -3
View File
@@ -37,16 +37,28 @@ ENV NODE_ENV=production \
IHASMAIL_VERSION=$IHASMAIL_VERSION \ IHASMAIL_VERSION=$IHASMAIL_VERSION \
BASE_PATH=$BASE_PATH BASE_PATH=$BASE_PATH
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json package-lock.json* ./
COPY server/package.json server/ COPY server/package.json server/
# config.ts reads the version through this at startup. With IHASMAIL_VERSION # 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 # 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. # than failing, since there is no git in here to ask.
COPY scripts/ ./scripts/ 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/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/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 USER node
# No `VOLUME ["/data"]`. It reads like documentation for where the session file # 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 # goes, but Docker acts on it: a container started without `-v` gets an