Reducing Setup Friction for Junior Engineers
Cut time-to-first-PR for junior engineers by fixing the container UID/GID mismatch that triggers EACCES on bind-mounted node_modules. Deterministic, CLI-driven.
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.
- Generate
.env.localwith the host identity:#!/usr/bin/env bash set -euo pipefail { echo "HOST_UID=$(id -u)" echo "HOST_GID=$(id -g)" } > .env.local - 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 - 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 - 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
- Commit a
setup.shthat auto-generates.env.localand runsdocker compose configto validate interpolation before the firstup. - Add a
Makefileparity 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; } - Run
make check-uidfrom a pre-push hook and in CI so corruptednode_modulesnever 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:cachedflag 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/creport 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