Files
intotheeast-com/docs/solutions/integration-issues/docker-exec-root-owned-bind-mount-files.md
T
m038andClaude Fable 5 9ffeb4d2d8 fix(make): build-assets drops privileges via --user; bump CLAUDE.md Grav to 2.0.7
Close the remaining root-owned bind-mount vector: build-assets (a docker
run, missed by the docker-exec fix in 209b804) now runs as the host
uid/gid with HOME=/tmp for npm's cache. Verified: build completes clean,
zero root-owned files under user/themes, bundles byte-identical.

Solution doc updated from "still open" to fixed; CLAUDE.md stack section
now matches the Dockerfile's Grav 2.0.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
2026-07-08 23:41:10 +02:00

12 KiB

title, date, last_updated, problem_type, category, module, component, severity, symptoms, root_cause, resolution_type, related_components, tags
title date last_updated problem_type category module component severity symptoms root_cause resolution_type related_components tags
docker exec/run defaults to root, writing root-owned files into the host bind mount 2026-07-08 2026-07-08 integration_issue integration-issues docker-dev-environment development_workflow high
11,624 root-owned (uid 0) files accumulated under the host ./user bind mount
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
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
config_error config_change
tooling
docker-compose
grav-cms
docker
docker-exec
docker-run
bind-mount
file-permissions
uid-gid
makefile
grav
gpm
build-assets
esbuild

Problem

In the Grav CMS travel-blog Docker dev environment, make targets that shelled into the grav container were silently creating root-owned (uid 0) files inside the host ./user bind mount. The grav service (based on getgrav/grav) bind-mounts host ./user/var/www/html/user, so anything the container writes there lands on the host filesystem with whatever ownership the writing process had.

Over time this accumulated 11,624 root-owned files under ./user. The immediate breakage: make worktree-rm could no longer delete a worktree's plugin tree, because a non-root host user cannot remove root-owned files without sudo. The working tree became unmanageable, and the per-worktree isolated-container workflow (which is what surfaced the accumulation) left root-owned debris behind on every teardown.

The root of the surprise: the developer had already set UID/GID env vars to their own user and reasonably assumed that covered container file ownership. It did not — those vars never reached the grav service.

Symptoms

  • ls -la user/plugins/... shows files owned by root root instead of the host user.
  • make worktree-rm (and a plain rm -rf on a worktree) fails with Permission denied on plugin files.
  • Thousands of root-owned files pile up under ./userfind ./user -uid 0 counted 11,624.
  • Confusing because UID/GID were already set to the developer's own user, yet ownership was still root.

What Didn't Work

Several plausible fixes were tried or considered and rejected:

  • Setting UID/GID env vars. These only reached the travel-memories service, which consumes them via its compose user: "${UID}:${GID}" directive. The grav service has no such directive, so it never consumed them.
  • APACHE_RUN_USER=#1000 / APACHE_RUN_GROUP=#1000 on the grav service. These only affect the Apache worker processes. They do nothing for docker exec CLI invocations or for the entrypoint — which are what the make targets actually run.
  • Adding user: "${UID}:${GID}" to the grav service in compose. Not viable. The getgrav/grav base-image entrypoint must boot as root to bind port :80 and set up cron. Pinning the whole container to a non-root user breaks boot.
  • Hardening worktree-rm to delete root files via a throwaway root container. Rejected by the user: no make command should require or use root privileges. The correct fix is to stop creating root-owned files, not to add a privileged cleanup step.

The symptom was noticed for weeks before it was diagnosed. (session history) During the earlier Grav 2.0.4/2.0.7 upgrade work, container-written files repeatedly surfaced as root-owned — the API plugin's generated config/plugins/api-private.php was flagged as "owned by the container, permission-denied to me", and worktree teardown already required git worktree remove --force to get past files it couldn't cleanly remove. Each instance was treated as a one-off annoyance rather than traced to docker exec defaulting to uid 0. Consolidating plugin management onto make install-plugins / gpm install during that upgrade actually enlarged the problem surface, because it increased how often the container writes into the host mount as root.

Root Cause

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.

There are two offenders, on two different bind mounts:

  • install-pluginsdocker exec … php bin/gpm install, writing the entire plugin tree into ./user/plugins as root. The worst by file count (11,624).
  • build-assetsdocker 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

Derive the host identity once in the Makefile and drop privileges on the specific exec that writes to the bind mount (commit 209b804).

Add host-user vars:

HOST_UID := $(shell id -u)
HOST_GID := $(shell id -g)

Rewrite install-plugins.

Before (wrote root-owned plugins):

install-plugins:
	docker exec -w /var/www/html $(GRAV_CONTAINER) php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
	$(MAKE) apply-plugin-patches

After (plugins owned by host user):

install-plugins:
	# cache/ and tmp/ are root-owned in the image, so make them writable first
	# (container-internal chown — never touches the host) so gpm can run AS YOU.
	docker exec $(GRAV_CONTAINER) chown -R $(HOST_UID):$(HOST_GID) /var/www/html/cache /var/www/html/tmp
	# gpm runs as the host user, so the plugins it writes into ./user/plugins are
	# owned by you, not root — no post-hoc chown, no root files to clean up later.
	docker exec -u $(HOST_UID):$(HOST_GID) -w /var/www/html $(GRAV_CONTAINER) php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
	$(MAKE) apply-plugin-patches

The build-assets vector (same principle, docker run) — fixed 2026-07-08

The first 2026-07-08 fix (209b804) hardened install-plugins only. build-assets remained 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)

The same drop-privileges principle applies — with --user on docker run (fixed later the same day):

# 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; HOME=/tmp gives npm a writable
# cache when running as a non-root uid
build-assets:
	docker run --rm --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
	  -v $(PWD)/user/themes/intotheeast:/app \
	  -w /app node:20-alpine \
	  sh -c "npm install && npm run build"

Verified: make build-assets with the fix completes clean (esbuild bundles emitted), find user/themes/intotheeast -uid 0 counts zero, and the output bundles are byte-identical to the previously committed ones. Recovery for any pre-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

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 preliminary chown of cache/ and tmp/ is container-internal: those paths are root-owned in the base image and are not host-managed content in the same way. gpm needs them writable to run as a non-root user; without making them writable first, gpm exits 1. Chowning them inside the container never touches the host filesystem.

Empirical validation. A minimal touch/stat test isolates the mechanism: docker exec -u 1000:1000 <container> touch /var/www/html/user/probe produces a host file owned by 1000, while the same command without -u produces one owned by 0. After applying the fix, make fix-perms cleared the backlog (11,624 → 0) and a real make install-plugins ran clean: gpm exit 0, zero root-owned files created, api/admin2 plugins owned by the host user, and the site healthy (/ and /admin → 200).

Prevention

The reusable principle, worth internalizing beyond this one repo:

  • 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) was 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).
  • 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.
  • If a tool run as non-root needs writable scratch dirs that are root-owned in the image, chown them container-internally first. That doesn't touch the host.
  • Root-owned files accumulate invisibly. (session history) Plugin code under user/plugins/<name>/ is gitignored by project convention (only cache-on-save, story-blocks, and entry-actions are tracked), so root-owned files pile up in the bind mount without ever appearing in git status — they only bite at worktree-removal time. Don't wait for git status to reveal them; find ./user -uid 0 | wc -l is the real detector.
  • Keep a make fix-perms escape hatch (container-internal chown -R 1000:1000 /var/www/html) for residual root files — notably first-boot files the base-image entrypoint writes as root (config/security.yaml, data/api-keys.yaml), which no -u on a make target can reach. After this fix it's a rare mop-up, not a routine step.
  • Verification recipe: docker exec -u 1000:1000 <container> touch /mnt/f && stat -c '%u' host/f should print your uid, not 0.

This lives in the Makefile because make targets are the only sanctioned container interface in this project — the fix belongs there, not in ad-hoc docker commands.

  • tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md — the sibling docker-dev-env doc. It documents make install-pluginsdocker exec … php bin/gpm install as a routine local step but never addresses who those execs run as. This doc is its complement: it explains why the exec must drop to the host user.
  • architecture-patterns/dual-repo-submodule-workflow.md — worktrees + the ./user submodule/bind mount, including the persistent M user dirty-state warning. Root-owned files landing in ./user from root-default execs are a concrete cause of unexpected permission/dirty state in worktree dev servers.
  • docs/guides/deploy-cycle.md — the three-layer state model (plugin code / repo config / host env tree); the host env tree is the layer across which these root-owned files land.