Files
claude-foundations/best-practices/docker-uid-matching.md
Paul O'Reilly 1b5e73dc54 Distill best practices from agent-runtimes M1-M3 memory files
12 additions/updates across 5 best-practice files:
- docker-uid-matching: userdel simplification, SSH agent socket UID match
- debugging: GIT_SSH_COMMAND scope limitation
- test-driven-development: subprocess mock gotcha, routing callables,
  Pydantic v2 field_validator defaults, sys.exit at module level
- spec-driven-development: multi-agent orchestration practices (commit WIP,
  self-verify, import conventions, assembly budget)
- validation: test pre-commit hooks after adding dependencies

Source: agent-runtimes/memory/ (decisions, gotchas-docker, gotchas-python,
process-lessons, m1/m2/m3 reflections)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:11:54 +13:00

5.6 KiB

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:

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:

#!/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 <new> <conflicting_user> followed by usermod -u <target> agent. Same applies to GIDs — use getent group to check before groupmod. Simpler alternative: Delete the conflicting user at build time (RUN userdel ubuntu in the Dockerfile). This avoids runtime conflict handling entirely and is preferred when you control the image.

  • SSH agent socket forwarding requires UID match. When mounting $SSH_AUTH_SOCK into a container, the socket is mode 0600 owned by the host UID. The container process must run as the same UID to use it — which the UID wrapper handles naturally. Set SSH_AUTH_SOCK in the container env to the mounted path. Note: private keys cannot be extracted via the agent protocol — it only supports signing operations.

  • 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.