docs(solutions): extend root-owned bind-mount doc to docker run / build-assets

The install-plugins fix (209b804) only covered docker exec. build-assets
runs `docker run node:20-alpine` without --user, so it still writes
root-owned node_modules + esbuild bundles into user/themes/ — which is what
blocked `git worktree remove` at teardown. Broaden the doc and prevention
rule to cover docker run, with the --user fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
This commit is contained in:
2026-07-08 22:30:14 +02:00
co-authored by Claude Opus 4.8
parent b205db0ea9
commit bc15f0b07d
@@ -1,6 +1,7 @@
--- ---
title: docker exec defaults to root, writing root-owned files into the host bind mount title: docker exec/run defaults to root, writing root-owned files into the host bind mount
date: 2026-07-08 date: 2026-07-08
last_updated: 2026-07-08
problem_type: integration_issue problem_type: integration_issue
category: integration-issues category: integration-issues
module: docker-dev-environment module: docker-dev-environment
@@ -11,6 +12,7 @@ symptoms:
- "make worktree-rm fails: cannot rm root-owned plugin files without sudo" - "make worktree-rm fails: cannot rm root-owned plugin files without sudo"
- "files stay root-owned even though UID/GID env vars were set to the host user" - "files stay root-owned even though UID/GID env vars were set to the host user"
- "install-plugins writes the entire plugin tree as root via php bin/gpm install" - "install-plugins writes the entire plugin tree as root via php bin/gpm install"
- "build-assets (docker run node:20-alpine, no --user) writes root-owned node_modules + esbuild bundles into user/themes/intotheeast/, blocking git worktree remove and git merge"
root_cause: config_error root_cause: config_error
resolution_type: config_change resolution_type: config_change
related_components: related_components:
@@ -20,12 +22,15 @@ related_components:
tags: tags:
- docker - docker
- docker-exec - docker-exec
- docker-run
- bind-mount - bind-mount
- file-permissions - file-permissions
- uid-gid - uid-gid
- makefile - makefile
- grav - grav
- gpm - gpm
- build-assets
- esbuild
--- ---
## Problem ## Problem
@@ -56,9 +61,12 @@ Several plausible fixes were tried or considered and rejected:
## Root Cause ## Root Cause
`docker exec` defaults to running as root (uid 0). Because the grav container must boot as root, and `docker exec` inherits that default unless `-u` is passed explicitly, every make target that did `docker exec <container> <cmd>` without `-u` wrote root-owned files into the `./user` bind mount. Both `docker exec` **and** `docker run` default to running as root (uid 0). Because the grav container must boot as root, and neither inherits a non-root default unless `-u` / `--user` is passed explicitly, every make target that shelled into (or spun up) a container without dropping privileges wrote root-owned files into whatever host path it bind-mounted.
The worst offender was `install-plugins`, which runs `php bin/gpm install` and writes the entire plugin tree into `./user/plugins`. There are **two** offenders, on two different bind mounts:
- **`install-plugins`** — `docker exec … php bin/gpm install`, writing the entire plugin tree into `./user/plugins` as root. The worst by file count (11,624).
- **`build-assets`** — `docker run --rm node:20-alpine … "npm install && npm run build"`, bind-mounting `./user/themes/intotheeast``/app`, writing root-owned `node_modules/` and esbuild bundle outputs (`js/…`, `css-compiled/`) into the tracked theme tree. This one uses **`docker run`**, not `docker exec`, and has **no `--user`** — so the `install-plugins` fix below does *not* cover it.
## Solution ## Solution
@@ -94,6 +102,30 @@ install-plugins:
$(MAKE) apply-plugin-patches $(MAKE) apply-plugin-patches
``` ```
### The `build-assets` vector (same principle, `docker run`) — still open
The 2026-07-08 fix (`209b804`) hardened `install-plugins` only. `build-assets` remains a root-writing target and surfaced later: `git worktree remove` aborted with `Permission denied` on root-owned esbuild bundles under `user/themes/intotheeast/js/post/`, and earlier a `build-assets` run had produced a root-owned `css-compiled/` dir that blocked a `git merge` on the main checkout. (session history)
Apply the same drop-privileges principle — with `--user` on `docker run`:
```makefile
# Before — writes root-owned node_modules + bundles into the tracked theme tree
build-assets:
docker run --rm \
-v $(PWD)/user/themes/intotheeast:/app \
-w /app node:20-alpine \
sh -c "npm install && npm run build"
# After — outputs owned by the host user (uid 1000)
build-assets:
docker run --rm --user $(HOST_UID):$(HOST_GID) \
-v $(PWD)/user/themes/intotheeast:/app \
-w /app node:20-alpine \
sh -c "npm install && npm run build"
```
Caveat: run as a non-root uid, npm needs a writable `$HOME`/cache. If the build errors on a read-only home dir, add `-e HOME=/tmp` (or `-e npm_config_cache=/tmp/.npm`). Recovery for the existing root-owned output is the same as anywhere else — `chown -R $(HOST_UID):$(HOST_GID)` from a container that already has root, then `rm`.
## Why This Works ## Why This Works
The container still *boots* as root — which it needs, to bind `:80` and set up cron. But the individual `docker exec` that writes into the bind mount now runs as the host uid/gid via `-u $(HOST_UID):$(HOST_GID)`. Files that exec creates on the host are therefore owned by the developer, not root. No post-hoc chown, no cleanup debt. The container still *boots* as root — which it needs, to bind `:80` and set up cron. But the individual `docker exec` that writes into the bind mount now runs as the host uid/gid via `-u $(HOST_UID):$(HOST_GID)`. Files that exec creates on the host are therefore owned by the developer, not root. No post-hoc chown, no cleanup debt.
@@ -106,7 +138,7 @@ The preliminary chown of `cache/` and `tmp/` is container-internal: those paths
The reusable principle, worth internalizing beyond this one repo: The reusable principle, worth internalizing beyond this one repo:
- **Any make/CI target that writes files into a host bind mount via `docker exec` must pass `-u $(HOST_UID):$(HOST_GID)`.** A container booting as root does *not* mean your exec commands must run as root. Drop privileges per-exec. - **Any make/CI target that writes files into a host bind mount must drop privileges — whether it uses `docker exec` (`-u $(HOST_UID):$(HOST_GID)`) or `docker run` (`--user $(HOST_UID):$(HOST_GID)`).** A container booting as root does *not* mean the commands you run in it must write as root. `build-assets` (a `docker run`) is the easy one to miss, because the original fix only patched the `docker exec` targets — so audit `docker run` invocations too, not just `docker exec`.
- **Derive host identity once in the Makefile and reuse it:** `HOST_UID := $(shell id -u)` / `HOST_GID := $(shell id -g)`. - **Derive host identity once in the Makefile and reuse it:** `HOST_UID := $(shell id -u)` / `HOST_GID := $(shell id -g)`.
- **Don't rely on `APACHE_RUN_USER` or compose-level `UID`/`GID` env vars to fix exec ownership** — they don't apply to `docker exec`. `APACHE_RUN_USER` only affects Apache workers; compose `user:`/env vars only affect services wired to consume them. - **Don't rely on `APACHE_RUN_USER` or compose-level `UID`/`GID` env vars to fix exec ownership** — they don't apply to `docker exec`. `APACHE_RUN_USER` only affects Apache workers; compose `user:`/env vars only affect services wired to consume them.
- **You can't just add `user:` to a service whose entrypoint needs root** (to bind privileged ports, set up cron, etc.). Drop privileges per-exec instead of per-container. - **You can't just add `user:` to a service whose entrypoint needs root** (to bind privileged ports, set up cron, etc.). Drop privileges per-exec instead of per-container.