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.

The reason this specific failure hurts juniors more than anyone else is not skill — it is context. A senior who has hit EACCES on a bind mount before recognises the shape of it in seconds and reaches for id -u. A first-week engineer sees a Node stack trace, assumes their code or their npm install is broken, and spends three hours down the wrong tunnel because nothing in the error mentions Docker, ownership, or UID. The cost is rarely the fix; it is the hours of misdirected searching before anyone realises the fix even exists. Reducing setup friction means removing the failure entirely, not documenting how to survive it.

The friction budget

Before fixing the mechanism, quantify what it costs, because that number is what justifies making UID parity a permanent onboarding gate rather than a one-off patch. On a team of ten with two new hires per quarter, an unguarded environment leaks the same afternoon repeatedly: every macOS contributor hits it, every one of them files the same Slack message, and a senior loses context-switching time answering it. The measurable target is time-to-first-PR — the wall-clock hours between git clone and a junior opening their first pull request. Environment friction, not code review, dominates that number on day one.

The numbers below are illustrative but track what teams consistently report: with no guard at all, the average macOS hire loses most of a day to the EACCES detour and the round-trips it triggers. Shipping a paragraph of README documentation helps only the readers who happen to read it before they hit the wall — roughly half — so it shaves the average without removing the tail. An automated UID gate that runs on first boot removes the failure for everyone, and the residual time is real onboarding work: reading the codebase, running the test suite, and writing the first change. The point of measuring is to make the difference between "documented" and "automated" visible to whoever owns the onboarding budget, because those two look identical in a README but differ by hours per hire.

Time to first pull request by setup approach Bar chart comparing hours to first PR for three onboarding setups. Time to First PR (hours) no guard 7.5h docs only 4.5h UID gate 1.3h
An automated UID parity gate collapses first-day setup from most of a day to under 90 minutes.

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. Capture both numbers side by side in the diagnostic so the mismatch is visible in one screenshot; that single artefact is usually enough for a reviewer to name the problem instantly rather than asking the new hire to run five more commands.

Two follow-up checks confirm you are looking at an ownership problem and not a genuinely read-only mount or a full disk. First, docker compose exec app id prints the container process's UID, GID, and supplementary groups — compare it directly against id -u on the host. Second, docker compose exec app ls -lnd /app/node_modules/.cache shows the numeric owner the container actually sees through the mount, which is the number that matters, not whatever ls -l resolves to a name on the host. When those two numbers differ and the mount is not marked :ro, the diagnosis is settled: nothing about the code, the lockfile, or the npm registry is involved.

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.

How a host and container UID mismatch produces EACCES Flow from a host file owned by UID 501 through a bind mount to a container process running as 1000, ending in a denied write. Why the Write Is Denied Host file owner 501:20 Bind mount ownership kept Process 1000 write to file VFS compares 1000 against 501 and returns EACCES.
The write fails because the container UID never owns the bind-mounted path.

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

The ${HOST_UID:-1000} default matters: it keeps the override safe for teammates who never generate .env.local (native Linux users at UID 1000), so nobody is forced through an extra step to keep the stack booting. Prefer a runtime user: directive over baking a useradd into the Dockerfile — the image stays identity-agnostic and the same tag runs correctly on a 501 macOS laptop, a 1000 Linux box, and a CI runner without a rebuild. Wire the whole sequence into the repository's make bootstrap target so a junior types one command and never learns any of this exists.

Ordered resolution sequence for UID injection Four ordered stages from capturing host identity to verifying the container write succeeds. Injection Sequence 1 — write HOST_UID to .env.local 2 — set user in Compose override 3 — recreate with --env-file 4 — verify write succeeds
Each step is a single runnable command; a bootstrap target chains all four.

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. The verification step is deliberately a touch on the bind mount rather than a full application boot: it isolates the ownership fix from every other startup variable, so when it passes you know the UID problem is closed regardless of whatever else the app does on launch. Keep this two-line check in the repo — it is the fastest possible confirmation that a new machine is configured correctly.

Prevention

  1. Commit a setup.sh that auto-generates .env.local and runs docker compose config to validate interpolation before the first up. Running config renders the fully-resolved Compose file, so a missing or empty HOST_UID surfaces as a visible user: ":" line instead of a runtime crash three commands later.
  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.

The deeper prevention principle is that setup friction is a budget, not an event: every manual step a junior must perform correctly is a place the environment can drift and a place a senior can be interrupted. Convert each "you have to remember to…" instruction in the README into an executable check that either passes silently or fails with a one-line explanation of the fix. A gate that prints UID MISMATCH between host and container teaches the reader more in one line than a paragraph of documentation they will never read at the moment they hit the wall.

Keep the gate cheap enough to run everywhere it is useful. The check-uid target above takes milliseconds and needs no network, so there is no cost to invoking it from a pre-push hook, from the bootstrap script, and as the first stage of the CI pipeline. Running the identical check in all three places closes the loop: a junior who somehow skipped the bootstrap script still trips the pre-push hook before they can push a node_modules corrupted by the wrong owner, and CI catches the case where the hook itself was bypassed with --no-verify. The failure that once cost an afternoon becomes a red line in a log that names its own remedy.

Platform caveats

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. Docker Desktop's VirtioFS backend does not remap identities either — the runtime user: directive remains the fix. WSL2: keep the repo under ~/ on the Linux filesystem — files on /mnt/c report a fixed root/999 ownership through the 9P/DrvFS translation layer and defeat UID mapping entirely. Cloning into the Windows filesystem is the most common reason the fix "works on my machine but not theirs." Apple Silicon (ARM64): no UID difference from an Intel Mac, but rebuild after the override (--build) so any native modules recompile under the new user and against the arm64 base image rather than an emulated amd64 layer.

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

Frequently Asked Questions

Why does EACCES only happen to my macOS teammates and not on Linux?

macOS user accounts start at UID 501, while most Node base images run the app as UID 1000. Bind mounts preserve host ownership, so a macOS-owned file at 501 is unwritable by a 1000 process. On native Linux the developer's account is frequently 1000 too, so host and container UIDs happen to align and the write silently succeeds — which is why the failure looks machine-specific rather than systemic.

Should I fix this with a useradd in the Dockerfile instead of a runtime user:?

No. Baking a fixed UID into the image only moves the mismatch — it works for whoever's UID you hardcoded and breaks for everyone else. A runtime user: "${HOST_UID:-1000}:${HOST_GID:-1000}" keeps the image identity-agnostic, so the same tag runs correctly on a 501 laptop, a 1000 Linux box, and a CI runner with no rebuild.

Does docker compose down -v during the fix delete data I care about?

Yes — -v removes named volumes, so any database contents in a named volume are destroyed. That is intentional in the resolution because you are recreating the stack under a new user and want no state owned by the old UID. If you need to keep a volume, back it up with docker run --rm -v vol:/data -v "$PWD":/backup alpine tar czf /backup/vol.tgz /data first.

Can I skip .env.local and just export the variables in my shell?

You can for a one-off up, but it will not persist and teammates will forget it, reintroducing the friction you are removing. Writing HOST_UID and HOST_GID to .env.local and passing --env-file (or letting Compose auto-load .env) makes the identity reproducible and lets a bootstrap script regenerate it automatically on any new machine.