From d19fa7ba4e982d517062fc4adf59c4fa098d4f3a Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 22 Sep 2026 12:04:36 +0200 Subject: [PATCH] Let the worker write to its own volumes A named volume whose mount point is absent from the image is created owned by root, so the worker - which drops root - could not have written a single copy into /instances. The failure would have surfaced at the first start_service, as a refused execution far from the line that caused it. Creating the two directories in the image and giving them to the runtime user is what makes the volume inherit that ownership. The user id follows as a build argument, because one number has to satisfy three things at once: who the process is, who owns the volumes, and who owns the workspace directory on the host. Left hardcoded, changing MCP_UID to match a deployment's own user would have brought the same fault back. The guard around useradd is for the ordinary case of pointing it at an id the base image already uses - 1000 is the node user - which is otherwise a build failure. Verified by building both ways and writing into a fresh volume as 10001 and as 1000, and by checking the image carries node 24, Python 3.11 and OpenJDK 25. Co-Authored-By: Claude Opus 5 (1M context) --- dev-server-mcp/Dockerfile | 27 ++++++++++++++++++++------- mcp-stack.compose.yml | 10 ++++++++-- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/dev-server-mcp/Dockerfile b/dev-server-mcp/Dockerfile index f435602..9728d18 100644 --- a/dev-server-mcp/Dockerfile +++ b/dev-server-mcp/Dockerfile @@ -23,15 +23,28 @@ RUN apt-get update \ # No Maven. A Spring project carries ./mvnw, which pins the Maven version the project is built # with; installing a second one here would only give an agent a way to use the wrong one. -RUN groupadd -g 10001 mcp \ - && useradd -r -u 10001 -g mcp -M -d /nonexistent mcp \ - && mkdir -p /tmp/dev-server \ - && chown -R mcp:mcp /tmp/dev-server +# One number decides three things that must agree: who the process is, who owns the volumes, and +# who owns the workspace directory on the host. Passing it in as a build argument is what keeps +# them from drifting apart - a mismatch here is only discovered when an execution cannot write. +ARG MCP_UID=10001 +ARG MCP_GID=10001 +RUN set -eux; \ + if ! getent group "${MCP_GID}" >/dev/null; then groupadd -g "${MCP_GID}" mcp; fi; \ + if ! getent passwd "${MCP_UID}" >/dev/null; then useradd -r -u "${MCP_UID}" -g "${MCP_GID}" -M -d /nonexistent mcp; fi; \ + mkdir -p /tmp/dev-server /instances /npm-cache; \ + chown -R "${MCP_UID}:${MCP_GID}" /tmp/dev-server /instances /npm-cache + +# /instances and /npm-cache exist here only so the named volumes mounted over them inherit this +# ownership. A volume whose mount point is absent from the image is created root-owned, and a +# container that drops root then cannot write a single byte into it - which surfaces as a refused +# start on the first execution, far from the line that caused it. WORKDIR /app -COPY --chown=mcp:mcp package.json ./ -COPY --chown=mcp:mcp src ./src +# By number, not by name: with MCP_UID set to an id the base image already uses, the mcp user +# is never created and a copy chowned to it would fail the build. +COPY --chown=${MCP_UID}:${MCP_GID} package.json ./ +COPY --chown=${MCP_UID}:${MCP_GID} src ./src ENV NODE_ENV=production -USER mcp +USER ${MCP_UID}:${MCP_GID} CMD ["node", "src/index.js"] diff --git a/mcp-stack.compose.yml b/mcp-stack.compose.yml index 1847e36..02f6605 100644 --- a/mcp-stack.compose.yml +++ b/mcp-stack.compose.yml @@ -29,12 +29,15 @@ services: dev-server-mcp: build: context: ./dev-server-mcp + args: + MCP_UID: ${MCP_UID:-10001} + MCP_GID: ${MCP_GID:-10001} command: ["node", "src/index.js"] environment: DEV_SERVER_MCP_API_KEYS: ${DEV_SERVER_MCP_API_KEYS} DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN} DEV_SERVER_WORKER_URL: http://dev-server-worker:4000 - user: "10001:10001" + user: "${MCP_UID:-10001}:${MCP_GID:-10001}" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m @@ -52,6 +55,9 @@ services: dev-server-worker: build: context: ./dev-server-mcp + args: + MCP_UID: ${MCP_UID:-10001} + MCP_GID: ${MCP_GID:-10001} command: ["node", "src/worker-index.js"] environment: DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN} @@ -155,7 +161,7 @@ services: mcp-gateway: image: caddy:2 - user: "10001:10001" + user: "${MCP_UID:-10001}:${MCP_GID:-10001}" read_only: true tmpfs: # /config only. /data is a volume instead, because Caddy keeps the certificate and its ACME