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
158 lines
12 KiB
Markdown
158 lines
12 KiB
Markdown
---
|
|
title: docker exec/run defaults to root, writing root-owned files into the host bind mount
|
|
date: 2026-07-08
|
|
last_updated: 2026-07-08
|
|
problem_type: integration_issue
|
|
category: integration-issues
|
|
module: docker-dev-environment
|
|
component: development_workflow
|
|
severity: high
|
|
symptoms:
|
|
- "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"
|
|
root_cause: config_error
|
|
resolution_type: config_change
|
|
related_components:
|
|
- tooling
|
|
- docker-compose
|
|
- grav-cms
|
|
tags:
|
|
- 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 `./user` — `find ./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-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
|
|
|
|
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:
|
|
|
|
```makefile
|
|
HOST_UID := $(shell id -u)
|
|
HOST_GID := $(shell id -g)
|
|
```
|
|
|
|
Rewrite `install-plugins`.
|
|
|
|
**Before** (wrote root-owned plugins):
|
|
|
|
```makefile
|
|
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):
|
|
|
|
```makefile
|
|
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):
|
|
|
|
```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; 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.
|
|
|
|
## Related
|
|
|
|
- [`tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`](../tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md) — the sibling docker-dev-env doc. It documents `make install-plugins` → `docker 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`](../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.
|