Skip to content

feat(docker): distroless nonroot runtime image - #64

Open
camden-bock wants to merge 7 commits into
PhilflowIO:mainfrom
camden-bock:feat/dockerfile-distroless
Open

feat(docker): distroless nonroot runtime image#64
camden-bock wants to merge 7 commits into
PhilflowIO:mainfrom
camden-bock:feat/dockerfile-distroless

Conversation

@camden-bock

@camden-bock camden-bock commented Aug 16, 2026

Copy link
Copy Markdown

Move the runtime to gcr.io/distroless/nodejs22-debian12:nonroot after splitting the Dockerfile into deps + runtime stages (base: node:22-alpine).

  • Security: runs as non-root (uid 65532); no shell, no package manager
  • Correctness: healthcheck uses /nodejs/bin/node (node is not on PATH in distroless)
  • Runtime: NODE_ENV=production baked in so direct image runs get Express prod mode

Changes / maintenance surface

  • Multi-stage Dockerfile: deps stage (node:22-alpine) + runtime stage (distroless)
  • Base image pins: node:22-alpine (deps), gcr.io/distroless/nodejs22-debian12:nonroot (runtime)
  • Runtime env: NODE_ENV=production baked in; PORT, BEARER_TOKEN, CALDAV_SERVER_URL, CALDAV_USERNAME, CALDAV_PASSWORD, CORS_ALLOWED_ORIGINS overridable at runtime
  • Healthcheck: exec-form via /nodejs/bin/node (no shell on distroless; not on PATH)
  • Non-root execution (uid 65532) — /app is root-owned read-only; revisit if the app ever needs runtime writes
  • npm ci --omit=dev in deps stage with BuildKit cache mount; requires package-lock.json in sync
  • git installed only in the deps stage (for tsdav/tsdav-utils git dependencies)
  • GHCR images will be built with this Dockerfile (docker format preserves HEALTHCHECK)

Test / validation

  • podman build --format docker succeeds
  • Container boots against a live CalDAV backend (Radicale)
  • /health returns 200
  • Healthcheck reports healthy (State.Health.status, exit 0)
  • Runs as non-root (Config.User == 65532)
  • No shell present (/bin/sh, /bin/bash absent)
  • NODE_ENV resolves to production inside the image
  • Image size reduced: 622 MB (node:22-alpine single-stage) → 170 MB (−73%)

Commits

  • chore(docker): bump base image from node:18-alpine to node:22-alpine
  • refactor(docker): split Dockerfile into deps and runtime stages
  • feat(docker): switch runtime stage to distroless nodejs22-debian12
  • fix(docker): use distroless nonroot variant
  • fix(docker): use absolute node path in healthcheck
  • fix(docker): set NODE_ENV=production in runtime stage
  • docs(docker): restore step comments in Dockerfile

Note: Podman's OCI image format silently drops HEALTHCHECK; all verification used --format docker, matching the docker-format images GHCR will publish.

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.
@camden-bock
camden-bock marked this pull request as ready for review August 16, 2026 18:20

@PhilflowIO PhilflowIO left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@PhilflowIO

Copy link
Copy Markdown
Owner

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 test.yml — Jest on Node 18/20/22 plus a node --check syntax pass over src/. This PR touches the Dockerfile and nothing else, so approving the workflow run here would go green without saying anything about the change. The local build we did is the stronger evidence, not the pipeline.

Where a run genuinely would tell us something is #65: that branch carries docker-publish.yml itself, and pull_request events execute workflows from the PR head, so approving there actually exercises the new build job — including the first real linux/arm64 build under QEMU, which has never happened. So treat the CI request as belonging to #65, not to this PR.

Everything else in the review stands: the two blockers and the digest pinning are what I'd like to see addressed here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants