Skip to content

File Ownership and UID/GID Mapping in a Mixed-OS Dev Environment — Summary

Summary of how container processes and the host user share files over bind mounts without permission conflicts, covering the UID/GID decision chain for a mixed Windows/macOS/Linux team.

· Intermediate
Requires
  • docker
  • vscode
  • make

File Ownership and UID/GID Mapping — Summary

Part 6 of the series. At this point the monorepo is bind-mounted at /repo into containers. This article solves the last remaining issue: two actors (the host user and the container process) accessing the same files.

The Core Problem

On Linux, file ownership is enforced by numeric UID/GID, not usernames. If the host user is UID 1001 and the container runs as UID 1000, the kernel treats them as strangers. This produces two failure modes:

  • Container writes → host can’t edit. Files created by the container are owned by a UID the developer doesn’t have. Editing or deleting them requires sudo.
  • Host writes → container can’t read. Less common because source files are typically world-readable (644), but possible with restrictive permissions.

Named volumes (node_modules, vendor, .venv) are immune — they are written and read exclusively by the container, and the host never touches them. The bind-mounted source tree is the only surface where this problem exists.

Three Constraints That Shaped the Decision

Mixed Windows/macOS/Linux team. UID 1000 is not portable. macOS silently hides UID conflicts via Docker Desktop’s VirtioFS remapping. Windows has no UID concept at all. A hardcoded UID is an invisible assumption that breaks unpredictably.

Caches and logs stay in the container. The bind mount is logically read-only at runtime. Any container writing into /repo at runtime is a misconfiguration to fix, not a permissions problem to work around. This eliminates the most common source of UID conflicts.

CI on GitHub Actions, prod on Kubernetes. docker compose is strictly a local developer tool. No environment needs container-to-host bind mount writes over the bind mount. The UID decision only needs to be correct for local development.

Options Evaluated

Option A — Fixed UID 1000

Hardcode appuser to UID 1000 in every Dockerfile dev stage. Works on macOS because Docker Desktop’s VirtioFS remapping hides the UID conflict. Works on Linux only for developers whose host UID happens to be 1000. Fails silently for others, and is meaningless on Windows.

Rejected — breaks unpredictably for part of the team with no obvious error cause.

Option B — Build-Arg UID Injection

Pass the host UID and GID into the image at build time via Docker build args. The container user is created with the exact UID/GID of the developer building the image. No UID conflict is possible because the container user is the host user, numerically.

Chosen.

Option C — user: Runtime Override in Compose

services:
    api:
        user: "${UID:-1000}:${GID:-1000}"

No image rebuild needed, but the container process runs as a UID with no entry in /etc/passwd. Anything that does a passwd lookup breaks: whoami, some Python libraries, PHP extensions, Git inside the container.

Rejected — fragile at the edges.

Option D — userns-remap at the Docker Daemon Level

Maps all container UIDs to a subordinate UID range on the host. Solves the problem at the infrastructure level but requires per-machine Docker daemon configuration. Unavailable on macOS via Docker Desktop.

Rejected — not realistic for a dev environment.

Option E — Run as Root in Dev

Don’t set USER in the dev stage. No permission conflicts because root can read and write anything. Violates dev/prod parity for security posture, VS Code warns when a devcontainer runs as root, and contradicts remoteUser: appuser decided in Part 4.

Rejected.

The Decision

Option B — build-arg UID injection, applied uniformly across all three service Dockerfiles.

# Every Dockerfile dev stage — api, worker, web
ARG UID=1000
ARG GID=1000

RUN groupadd -g ${GID} appuser \
 && useradd -u ${UID} -g ${GID} -m -s /bin/bash appuser

# All apt-get, chown, and other root operations above this line
USER appuser

Build args are supplied through docker-compose.override.yml, which is gitignored. A committed example file documents the pattern:

# docker-compose.override.yml.example
services:
    api:
        build:
            args:
                UID: ${UID:-1000}
                GID: ${GID:-1000}
    worker:
        build:
            args:
                UID: ${UID:-1000}
                GID: ${GID:-1000}
    web:
        build:
            args:
                UID: ${UID:-1000}
                GID: ${GID:-1000}

Values come from .env, populated by make init — the mandatory first step for every developer:

init:
	@echo "UID=$(shell id -u)" >> .env
	@echo "GID=$(shell id -g)" >> .env
	docker compose build

make init writes the host UID/GID into .env and builds all images. On macOS, Docker Desktop’s remapping layer makes the build arg largely redundant in practice — but the pattern is correct on all platforms and costs nothing extra.

Windows Developers: One Critical Constraint

On Windows with the WSL2 backend, files on the Windows filesystem (/mnt/c/...) receive synthetic ownership 1000:1000 regardless of build args, and filesystem performance through that path is significantly worse.

The repo must live on the WSL2 filesystem. Run make init from inside a WSL2 terminal. id -u inside WSL2 returns the WSL2 Linux UID, which is what matters.

This is the single most impactful setup decision for a Windows developer on this stack. Document it first in the onboarding README. It is not a workaround — it is the correct way to use Docker on Windows.

GitHub Actions

CI does not rely on files written from inside a container back to the runner filesystem. Workflows build images, run tests, and collect exit codes. Any test output that needs to leave the container is extracted explicitly:

- name: Extract test results
  run: docker cp $(docker ps -lq):/workspace/coverage ./coverage

Or via a dedicated output volume. Never via bind mount writes from inside the container.

Production Divergence — PHP-FPM and www-data

In the production image stage, PHP-FPM runs as www-data (UID 33 on Debian bookworm). Dev runs as appuser with a UID derived from the build arg (default 1000). This is an accepted and explicitly documented dev/prod divergence.

Do not attempt to align them by making dev use www-data. UID 33 on a Linux developer’s host belongs to a system account. Bind-mounted files created inside the container as www-data would be uneditable without sudo. The divergence is harmless: the bind mount is read-only at runtime, and named volumes are container-internal.

The api Dockerfile carries a comment in the prod stage to prevent this from being “fixed” by a future developer:

# --- prod stage ---
# PHP-FPM runs as www-data (UID 33) in production.
# Dev runs as appuser (UID from build arg, default 1000).
# This divergence is intentional — do not change dev to match.
# Aligning to www-data breaks host file ownership on Linux developers.
USER www-data

What Is Now Closed

QuestionDecision
UID strategyBuild-arg injection (Option B)
Default UID1000 (overridden per developer via make init)
Bind mount write disciplineContainer never writes into /repo at runtime
Named volume ownershipContainer-internal, UID-agnostic — closed in Part 5
Windows setupRepo on WSL2 filesystem, make init from WSL2 terminal
CI bind mount writesNot used — artifacts extracted explicitly
PHP-FPM prod userwww-data, divergence from dev is intentional and documented

Open Items Entering Part 7

.env now carries two distinct categories of content: developer infrastructure (UID, GID) and application secrets (database credentials, API keys, etc.). Part 7 — environment variables and secrets — needs to address whether these live in one file or are split. Mixing infrastructure concerns with secrets in a single .env is worth a deliberate call before anyone writes a service that reads from it.

The broker image remains deferred from Part 3.