A junior engineer runs docker compose up, the app crashes with EACCES: permission denied on a bind-mounted node_modules, and an afternoon vanishes into filesystem-ownership debugging that has nothing to do with the feature they were assigned. This is the single most common first-day blocker, it has one root cause, and it has a deterministic fix. The fix slots into the broader catalogue of common local failure points under onboarding architecture and friction mapping.

Diagnostic

Reproduce the failure and capture the ownership mismatch behind it.

#!/usr/bin/env bash
set -euo pipefail
docker compose up -d
docker compose logs --tail 50 app | grep -iE 'EACCES|permission denied|EPERM' || true
# Compare host ownership against the container's runtime user
stat -c '%u:%g' ./node_modules/.cache 2>/dev/null || echo "host cache dir missing"
docker compose exec app stat -c '%u:%g' /app/node_modules/.cache

Expected BAD output — the host directory is owned by a UID the container process does not run as:

Error: EACCES: permission denied, open '/app/node_modules/.cache/index'
501:20
1000:1000

The host cache is owned by 501:20 (a typical macOS user), while the container process runs as 1000:1000. The kernel enforces POSIX permissions at the VFS layer and rejects the write. The symptom is loud — a stack trace ending in EACCES — but the cause is invisible to anyone who has not seen it before, which is exactly why it eats a junior engineer's afternoon: they search for the npm error, not the ownership mismatch underneath it.

Root Cause

Bind mounts preserve host ownership. macOS user accounts start at UID 501, most Linux base images run their app as UID 1000 (the node user), and Docker does not remap between them. So the container process — running as 1000 — has no write permission on files the bind mount presents as owned by 501. This is not a Docker bug; it is an unstated expectation that host and container UIDs match. On native Linux the two often happen to align at 1000, which is why the failure looks intermittent across a team: it reliably bites macOS contributors and silently spares everyone on Linux, making it easy to dismiss as "something wrong with their laptop." The same class of hidden, host-specific drift is what runtime parity frameworks exist to eliminate, and treating UID parity as a first-class onboarding check stops it recurring with every new hire.

Resolution

Inject the host's UID/GID at runtime instead of hardcoding either side.

  1. Generate .env.local with the host identity:
    #!/usr/bin/env bash
    set -euo pipefail
    {
      echo "HOST_UID=$(id -u)"
      echo "HOST_GID=$(id -g)"
    } > .env.local
  2. Run the container as that identity via a Compose override:
    # docker-compose.override.yml
    services:
      app:
        user: "${HOST_UID:-1000}:${HOST_GID:-1000}"
        volumes:
          - .:/app:cached
  3. Recreate the stack cleanly so the new user takes effect:
    #!/usr/bin/env bash
    set -euo pipefail
    docker compose --env-file .env.local down -v
    docker compose --env-file .env.local up --build -d
  4. Verify the container can now write to the bind mount:
    #!/usr/bin/env bash
    set -euo pipefail
    docker compose exec app touch /app/testfile
    docker compose exec app stat -c '%u:%g' /app/testfile

Expected Output

After the override, the injected UID matches the runtime user and the write succeeds:

1000:1000

If your host UID is 501, both the file and the process now report 501:501, and EACCES no longer appears in the app logs.

Prevention

  1. Commit a setup.sh that auto-generates .env.local and runs docker compose config to validate interpolation before the first up.
  2. Add a Makefile parity gate that fails fast if host and container UIDs diverge:
    .PHONY: check-uid
    check-uid:
    	@test "$$(id -u)" -eq "$$(docker compose exec -T app id -u)" \
    		|| { echo 'UID MISMATCH between host and container'; exit 1; }
  3. Run make check-uid from a pre-push hook and in CI so corrupted node_modules never enter version control. Fold this into the repository's onboarding health-check script so it runs automatically on first boot.

macOS (Docker Desktop): host accounts start at UID 501; the :cached flag helps I/O but does not change ownership, so the UID injection above is still required. WSL2: keep the repo under ~/ on the Linux filesystem — files on /mnt/c report root ownership and defeat UID mapping entirely. Apple Silicon (ARM64): no UID difference, but rebuild after the override (--build) so any native modules recompile under the new user.

Rollback

#!/usr/bin/env bash
set -euo pipefail
docker compose down -v --remove-orphans
rm -f .env.local docker-compose.override.yml
git checkout -- docker-compose.yml