diff --git a/BESTPRACTICES.md b/BESTPRACTICES.md index e818477..3659e2c 100644 --- a/BESTPRACTICES.md +++ b/BESTPRACTICES.md @@ -18,3 +18,5 @@ Generalised best practices extracted from real project work. Each topic file is - [Linting & Formatting](best-practices/linting.md) — Tool choices per language, PostToolUse hook, pre-commit integration, formatter contract - [Spec-Driven Development](best-practices/spec-driven-development.md) — Spec structure, requirement numbering, test-first workflow, context tiers, anti-patterns - [Test-Driven Development](best-practices/test-driven-development.md) — Edge case discovery, property-based testing, mutation testing, AI agent testing patterns, test architecture +- [Networking & Infrastructure](best-practices/networking.md) — nftables safety, systemd socket activation, Docker forwarding, TLS SNI vs Host header, wildcard certs +- [Docker UID Matching](best-practices/docker-uid-matching.md) — UID wrapper entrypoint for mounted volumes, gosu pattern, when to use vs K8s securityContext diff --git a/best-practices/docker-uid-matching.md b/best-practices/docker-uid-matching.md new file mode 100644 index 0000000..757c229 --- /dev/null +++ b/best-practices/docker-uid-matching.md @@ -0,0 +1,111 @@ +# Docker UID Matching for Mounted Volumes + +When a host directory is mounted into a Docker container, files created by the container process are owned by the container user's UID/GID. If this doesn't match the host user, you get one of two problems: + +1. **Permission denied** — the container can't write to the mounted directory +2. **Wrong ownership** — files created inside the container are owned by a different user on the host (e.g., `root` or UID `1001`) + +Both are common sources of friction in developer-facing container tools and CI/CD pipelines. + +## The Pattern: UID Wrapper Entrypoint + +The solution is a small entrypoint wrapper script that: + +1. Starts as root +2. Detects the UID/GID of the mounted directory via `stat` +3. Adjusts the container user's UID/GID to match using `usermod`/`groupmod` +4. Drops privileges via `gosu` and execs the real command + +This is transparent to the user — they don't need to pass `--user` flags or know their UID. + +### Implementation + +**Dockerfile:** + +```dockerfile +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y --no-install-recommends gosu \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -s /bin/bash agent +RUN mkdir -p /project && chown agent:agent /project + +COPY uid-wrapper.sh /usr/local/bin/uid-wrapper.sh + +# Start as root — the wrapper drops privileges after UID adjustment +ENTRYPOINT ["uid-wrapper.sh", "your-actual-command"] +``` + +**uid-wrapper.sh:** + +```bash +#!/bin/bash +set -e + +TARGET_DIR="/project" # The mount point to match +AGENT_USER="agent" # The non-root user to adjust + +# If not root, skip adjustment (e.g., K8s securityContext sets UID) +if [ "$(id -u)" != "0" ]; then + exec "$@" +fi + +if [ -d "$TARGET_DIR" ]; then + HOST_UID=$(stat -c '%u' "$TARGET_DIR") + HOST_GID=$(stat -c '%g' "$TARGET_DIR") + + if [ "$HOST_UID" != "0" ]; then + # Adjust GID if different + if [ "$HOST_GID" != "$(id -g $AGENT_USER)" ]; then + groupmod -g "$HOST_GID" "$AGENT_USER" 2>/dev/null || true + fi + # Adjust UID if different + if [ "$HOST_UID" != "$(id -u $AGENT_USER)" ]; then + usermod -u "$HOST_UID" "$AGENT_USER" 2>/dev/null || true + fi + # Fix home directory ownership + chown -R "$AGENT_USER:$(id -g $AGENT_USER)" /home/"$AGENT_USER" 2>/dev/null || true + fi +fi + +exec gosu "$AGENT_USER" "$@" +``` + +### Key Details + +- **`gosu` over `su`/`sudo`.** `gosu` execs directly (PID 1 becomes the real process), while `su` creates a child process that breaks signal handling. `gosu` is the standard tool for this pattern. + +- **Handle existing UIDs/GIDs.** The host UID may already be taken by another user in the container. Ubuntu 24.04 images ship with a `ubuntu` user at UID 1000 — the most common host UID. The wrapper must evict the conflicting user to a high unused UID (e.g., 59999 and search downward) before assigning the target UID to the agent user. `usermod -u ` followed by `usermod -u agent`. Same applies to GIDs — use `getent group` to check before `groupmod`. + +- **Skip when not root.** In Kubernetes, `securityContext.runAsUser` sets the UID before the container starts. The wrapper detects this (`id -u != 0`) and skips adjustment — the platform is handling it. + +- **Mount point owned by root.** If the mounted directory is owned by root (UID 0), don't adjust — running as root defeats the purpose. The wrapper only adjusts for non-root UIDs. + +- **Fix home directory.** After `usermod`, the user's home directory still has the old UID. `chown -R` fixes this. Skip other directories — only fix what the user needs. + +## When to Use + +- **Developer-facing container tools** where users mount local project directories (e.g., running a CLI tool inside a container) +- **CI/CD containers** that write build artifacts back to a mounted workspace +- **Any container that writes to a host-mounted volume** and the output needs correct ownership + +## When NOT to Use + +- **Kubernetes with securityContext** — the platform handles UID assignment; the wrapper correctly skips adjustment in this case +- **Containers that don't write to mounted volumes** — unnecessary overhead +- **Images that must run as root** — the wrapper's purpose is to run as non-root; if you need root, you don't need the wrapper + +## Alternatives + +| Approach | Pros | Cons | +|----------|------|------| +| `docker run --user $(id -u):$(id -g)` | Simple, no image changes | No home directory, no `/etc/passwd` entry, breaks tools that need a user identity | +| `fixuid` | Purpose-built tool | Another binary to install and maintain | +| UID wrapper (this pattern) | Transparent, handles edge cases, works in Docker and K8s | Requires `gosu` in image, starts as root | +| Named volumes only | Docker manages ownership | Can't mount host directories | + +## Source + +Learned from the agent-runtimes project (M1) where Claude Code refuses `--dangerously-skip-permissions` as root for security reasons. The container needed to run as non-root, but files created in mounted project directories needed to be owned by the host user. The UID wrapper solved both problems transparently.