feat(docker): distroless nonroot runtime image - #64
Conversation
Node 18 is past EOL. No other changes yet — structure and behavior stay identical, this is purely the version bump.
tsdav and tsdav-utils are installed via the github: protocol, so npm ci needs git available -- but only during install, never in the runtime image. Split into a deps stage (git + npm ci with a BuildKit cache mount) and a runtime stage that copies just node_modules and src. Runtime base stays node:22-alpine for now; the distroless switch is a separate commit. Verified: builds cleanly and serves /health (200) against a local Radicale backend.
No native/node-gyp dependencies in package.json, so distroless is viable without alpine/musl compatibility risk. Drops the manual addgroup/adduser/chown lines from the old Dockerfile -- distroless runs non-root at uid 65532 by default. CMD switches to the exec-args form (no "node" prefix) since the distroless nodejs image bakes node in as the entrypoint. Trade-off: no shell in the final image (confirmed: `podman exec sh` fails with "executable file not found"). Anyone needing to poke around a running container should use the :debug variant (gcr.io/distroless/nodejs22-debian12:debug) with `docker debug`. Verified: builds cleanly, runs as non-root, serves /health (200) against a local Radicale backend, no shell present as expected.
PhilflowIO
left a comment
There was a problem hiding this comment.
Thanks — this is careful work, and I verified the core of it locally rather than taking the checklist on trust: the image builds, boots, reports healthy, runs as uid 65532, and has no shell. Size on my machine went 789 MB → 238 MB (different tooling than yours, same direction). Two things block the merge, two claims in the description don't hold, and one nit got promoted to a real request.
Blocker — docker-compose.yml is left broken
The PR doesn't touch it, and its healthcheck override calls bare node, which isn't on PATH in distroless. Measured against a demonstrably healthy server running your image:
{"Status":"unhealthy","FailingStreak":3,
"Output":"OCI runtime exec failed: exec: \"node\": executable file not found in $PATH"}
Re-running the same probe with /nodejs/bin/node inside that container returns healthy. Since docker-compose up is the only deployment path the README documents (README.md:77), the fix belongs in this PR: either drop the override so the image HEALTHCHECK applies, or use the absolute path. Shell form isn't an option either — /bin/sh is absent.
Blocker — the PORT promise isn't kept
The Dockerfile comment advertises PORT as overridable at runtime, but the healthcheck hardcodes :3000 while src/server-http.js:41 reads process.env.PORT. With PORT=8080 the probe fails against a perfectly healthy server. Since the healthcheck is a node process, it can read the env itself:
require('http').get('http://localhost:' + (process.env.PORT || 3000) + '/health', ...)
Promoted from nit — please pin the base images by digest
Both node:22-alpine and gcr.io/distroless/nodejs22-debian12:nonroot are mutable tags. My original reason for raising this was reproducibility, which is the weaker argument; the stronger one is that a tag can be repointed at a different image without a single line changing in this repo. That is precisely how tj-actions/changed-files (CVE-2025-30066) played out. For an image that third parties will docker pull and run, I'd like @sha256:… on both.
I'm aware of the trade-off: digests don't move, so they need to be bumped deliberately. Worth noting that the automation story is already weak here regardless — the distroless nonroot tag is non-semver, so the runtime base gets no automatic updates either way.
Two description claims that don't match the image
/app is not "root-owned read-only". WORKDIR is created after the base image's USER, so it ends up uid 65532, gid 65532, mode 755 — owned by and writable for the runtime user. Only its contents (/app/src) are root-owned; a write there does fail with EACCES. No Dockerfile bug, but the security claim as written doesn't hold, and --read-only isn't set anywhere.
apk add --no-cache git is unnecessary, and the comment explaining it is factually wrong. node:22-alpine ships no git, and npm ci --omit=dev against this exact lockfile succeeds there anyway (added 104 packages) — npm resolves the GitHub deps as codeload tarballs, not over the git protocol. Dropping the layer saves build time.
Verified and fine, for the record
Entrypoint and CMD resolve correctly ([/nodejs/bin/node] | [src/server-http.js] | user=65532 | wd=/app); the container boots fully and the only error is the intended CalDAV connection failure. COPY package.json ./ is load-bearing, not cosmetic — src/server-info.js:12-13 reads it at import time, and without "type": "module" every import would break. The runtime write path survives: src/tool-call-logger.js:133 defaults to /tmp/mcp-tool-calls.jsonl, and distroless ships /tmp as 1777. tsdav's prepare hook (husky && npm run build) is a classic --omit=dev trap but does not fire here. Node 18 → 22 breaks nothing: engines is >=18.0.0 and test.yml already covers 22.x.
One process note: CI has never actually run on this branch (fork approval pending), so every checkbox above is a local run. I'd like to see a real CI pass before merging.
|
Correction to the last line of my review: asking for "a real CI pass before merging" doesn't mean much on this branch, and I should have checked before writing it. The only CI this repo has is Where a run genuinely would tell us something is #65: that branch carries Everything else in the review stands: the two blockers and the digest pinning are what I'd like to see addressed here. |
Move the runtime to
gcr.io/distroless/nodejs22-debian12:nonrootafter splitting the Dockerfile into deps + runtime stages (base:node:22-alpine)./nodejs/bin/node(node is not on PATH in distroless)NODE_ENV=productionbaked in so direct image runs get Express prod modeChanges / maintenance surface
depsstage (node:22-alpine) +runtimestage (distroless)node:22-alpine(deps),gcr.io/distroless/nodejs22-debian12:nonroot(runtime)NODE_ENV=productionbaked in;PORT,BEARER_TOKEN,CALDAV_SERVER_URL,CALDAV_USERNAME,CALDAV_PASSWORD,CORS_ALLOWED_ORIGINSoverridable at runtime/nodejs/bin/node(no shell on distroless; not on PATH)/appis root-owned read-only; revisit if the app ever needs runtime writesnpm ci --omit=devin deps stage with BuildKit cache mount; requirespackage-lock.jsonin syncgitinstalled only in the deps stage (for tsdav/tsdav-utils git dependencies)HEALTHCHECK)Test / validation
podman build --format dockersucceeds/healthreturns 200healthy(State.Health.status, exit 0)Config.User== 65532)/bin/sh,/bin/bashabsent)NODE_ENVresolves toproductioninside the imageCommits