build(docker): Switch to Docker Hardened Images (DHI)#212
Conversation
Replace node:24.14.0 and node:24.14.0-slim base images with Sentry's DHI equivalents (us-docker.pkg.dev/sentryio/dhi/node:24-debian13-dev and node:24-debian13). Move canvas native compilation (yarn install + build deps) entirely into the builder stage so the runtime image no longer needs build-essential or -dev headers. The runtime stage only installs the minimal shared libraries that canvas needs at runtime (libcairo2, libpango-1.0-0, libjpeg62-turbo, libgif7, librsvg2-2), eliminating ~1,400 Trivy findings that were rooted in the Debian system package footprint. Co-Authored-By: Claude <noreply@anthropic.com>
The DHI manifest entries for amd64 have an unexpected variant field
("v8"), which is normally an ARM designator. Docker BuildKit fails to
match linux/amd64 against these entries.
Add --platform=linux/amd64 to both FROM lines so BuildKit selects by
architecture directly rather than relying on variant matching.
Co-Authored-By: Claude <noreply@anthropic.com>
The DHI manifest non-standardly labels the amd64 image with variant "v8" (an ARM designator), making its platform string linux/amd64/v8 rather than the expected linux/amd64. Cloud Build's BuildKit fails to match linux/amd64 against linux/amd64/v8. Specify the exact platform string from the manifest so BuildKit resolves the correct image layer. Co-Authored-By: Claude <noreply@anthropic.com>
The DHI dev image has libexpat1=2.7.4 (arch:all) pre-installed as a security patch, which conflicts with the entire canvas build dep chain (libcairo2-dev → libfontconfig-dev → libexpat1-dev requires libexpat1 = 2.7.1-2 arch-specific). Both APT solver 3.0 and the classic solver fail to resolve this. Use standard node:24.14.0 for the builder stage (compiles canvas native module and TypeScript without package conflicts). The runtime stage uses DHI node:24-debian13-dev since the fully minimal node:24-debian13 image has no shell or package manager — canvas needs runtime shared libraries (libcairo2, libpango-1.0-0, libjpeg62-turbo, libgif7, librsvg2-2) which apt-get install correctly on Debian 13 in the -dev image. The --platform=linux/amd64/v8 flag is scoped to only the DHI FROM line because the DHI manifest incorrectly labels the amd64 entry with variant "v8" (an ARM designator). Standard node:24.14.0 uses linux/amd64 without a variant, so the platform flag must not apply to that stage. Compiled node_modules (including canvas.node native binary) is copied from the builder so the runtime stage never needs build tools. Co-Authored-By: Claude <noreply@anthropic.com>
canvas 3.x bundles its own copies of libcairo, libpango, libjpeg, libgif, librsvg, harfbuzz, glib, etc. inside node_modules/canvas/build/Release/. No system-level canvas libraries need to be installed in the runtime image. The -dev variant is still required over the minimal node:24-debian13 image because canvas's bundled dependencies (librsvg, glib) need base system libs (libz, libexpat, libuuid, liblzma) that are present in the dev image but absent from the stripped-down minimal image. Co-Authored-By: Claude <noreply@anthropic.com>
canvas 3.x downloads pre-built binaries via node-pre-gyp, so the build stage no longer needs system canvas libraries (libcairo2-dev, libpango1.0-dev, etc.). This removes the conflict with DHI's pre-installed libexpat1 2.7.4 package that previously blocked using the hardened image in the builder stage. Also removes the --platform=linux/amd64/v8 workaround now that the DHI image manifests have correct platform variant labels. Co-Authored-By: Claude <noreply@anthropic.com>
Switch the runtime stage from node:24-debian13-dev to node:24-debian13 (the minimal/distroless image) to reduce the attack surface. canvas 3.x bundles its graphics libs (libcairo, libpango, etc.) so no system canvas libraries are needed. However, canvas's bundled librsvg and glib still require four basic system libs absent from the minimal image (libz, libexpat, libuuid, liblzma). These are collected in the builder stage and copied into the runtime, rather than pulling in the full -dev image. The smoke-test RUN uses exec form since the minimal image has no shell. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| COPY --from=builder /canvas-sys-libs/ / | ||
|
|
||
| RUN node lib/index.js --help | ||
| RUN ["node", "lib/index.js", "--help"] |
There was a problem hiding this comment.
Bug: The Dockerfile's smoke test uses node lib/index.js --help, which never imports the canvas module, failing to validate its native dependencies.
Severity: CRITICAL
Suggested Fix
Modify the smoke test to invoke a command that actually loads the canvas module. For example, run a minimal render command that triggers the import of render.ts. This will ensure that the canvas module and its system library dependencies are correctly loaded and validated during the Docker build.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: Dockerfile#L36
Potential issue: The smoke test command `node lib/index.js --help` exits before any
command handlers are executed. Consequently, the `canvas` module and its native
dependencies are never loaded or tested during the build process. If the required system
libraries for `canvas` are missing from the builder image, the build will still succeed,
but the application will crash at runtime when the first render request is made and it
attempts to load the `canvas` module. This creates a situation where a non-functional
image can be deployed to production.
Did we get this right? 👍 / 👎 to inform future reviews.
There was a problem hiding this comment.
Let's ignore, that's the previous behavior.
Add a separate deps stage that runs yarn install --production so that jest, typescript, eslint, ts-jest, supertest, and their transitive deps are not copied into the runtime image. The builder stage keeps the full install for TypeScript compilation. node_modules layer in the runtime image shrinks from ~94 MB to ~46 MB. Co-Authored-By: Claude <noreply@anthropic.com>
Merge the separate deps and builder stages into a single builder stage. Install all dependencies, compile TypeScript, then prune to production-only deps in place before copying into the runtime image. Runtime image contents remain identical. Co-Authored-By: Claude <noreply@anthropic.com>
Switch both builder and runtime stages to Docker Hardened Images (DHI).
Before: builder and runtime both used
node:24.14.0/node:24.14.0-slim(Debian 12), with canvas system libraries installed via apt-get in the runtime. Trivy reported 1,457 vulnerabilities (3 CRITICAL, 148 HIGH) across 288 OS packages, compressed image size ~502 MB.After: builder uses
us-docker.pkg.dev/sentryio/dhi/node:24-debian13-dev, runtime uses the minimalus-docker.pkg.dev/sentryio/dhi/node:24-debian13(Debian 13, distroless — no shell, no apt). Trivy reports 10 vulnerabilities (0 CRITICAL, 1 HIGH) across 24 OS packages, compressed image size ~85 MB.How the builder conflict was resolved
DHI's pre-installed
libexpat1 2.7.4 (arch:all)conflicts with canvas's build dependencies (libcairo2-dev → libfontconfig-dev → libexpat1-devrequires exactlylibexpat1 = 2.7.1-2). This turned out not to matter: canvas 3.x uses node-pre-gyp to download pre-built binaries at install time, so no system canvas libraries are needed in the builder at all.How the minimal runtime was made to work
canvas 3.x bundles all its graphics libs (libcairo, libpango, librsvg, etc.) in
node_modules/canvas/build/Release/, so no system canvas libraries are needed at runtime either. However, the bundled librsvg/glib still need four basic system libs absent from the distroless image (libz,libexpat,libuuid,liblzma). These are collected in the builder stage and copied into the runtime withCOPY --from=builder /canvas-sys-libs/ /, avoiding the need for the full-devimage.The smoke-test
RUNuses exec form since the minimal image has no shell.