npm install or a runtime file write fails inside a container with EACCES: permission denied because the container process runs as UID 1000 while the bind-mounted directory maps to root. This page aligns host and container identities to restore deterministic write access; it extends volume mounting and hot-reload optimization within the broader containerized local environment patterns.

The symptom is misleading because the same docker-compose.yml works on a Linux host and fails on a colleague's Mac or Windows laptop. Nothing in the Compose file changed — what changed is the filesystem boundary between the host and the Linux VM that Docker Desktop runs. The fix is not chmod 777; that only masks the mismatch and re-breaks the moment a new file is created. The durable fix pins the container's runtime UID and GID to values the mount layer can present as writable, and bakes that identity into the image so every teammate and CI runner behaves identically.

Diagnostic

Permission failures surface during package installation or the first runtime write. Typical signatures:

  • EACCES: permission denied during npm install, pip install, or go mod download
  • chown: changing ownership of '/app/node_modules': Operation not permitted
  • error: could not create directory '/app/.cache': Permission denied
  • Host stat showing UID 501 (macOS) while the container expects 1000:1000

Isolate the user context and directory ownership inside the running service. The id command reveals which UID the process actually runs as, and ls -ln prints numeric owners so you are comparing raw IDs rather than names that may not resolve inside the container:

#!/usr/bin/env bash
set -euo pipefail
svc=app
docker compose exec -T "$svc" id
docker compose exec -T "$svc" ls -ln /app
docker compose exec -T "$svc" stat -c '%u:%g %n' /app /app/node_modules

Expected BAD output — the directory is owned by UID 0 but the process is UID 1000, so any write into it is denied:

uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)
total 4
drwxr-xr-x 1 0 0 4096 Oct 24 08:12 node_modules
0:0 /app
0:0 /app/node_modules

Confirm the mount is a bind mount crossing the VM boundary rather than a native named volume, because the two fail for different reasons. Inspect the container mount table and note the Type and Source:

#!/usr/bin/env bash
set -euo pipefail
docker inspect -f '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' \
  "$(docker compose ps -q app)"

A bind type whose Source is a host path under /Users or /mnt/c confirms the write is being routed across the host-to-VM translation layer described below.

Ownership loss across the mount boundary A host file owned by UID 501 crosses the Docker Desktop VM translation layer and is presented to the container as root, which the UID 1000 process cannot write. Where Ownership Is Lost Host file owner 501:20 (macOS) ./src on disk VM mount layer gRPC-FUSE / VirtioFS strips POSIX owner Container view shows 0:0 (root) UID 1000 write denied The host owner never reaches the container — the boundary reassigns it.
Bind mounts on Docker Desktop lose POSIX ownership at the VM boundary, so a non-root process sees root-owned files.

Root Cause

Docker Desktop on macOS and Windows does not run Linux containers natively — it provisions a lightweight Linux VM and routes bind mounts across the host-to-VM boundary via gRPC-FUSE (macOS) or VirtioFS (Windows/WSL2, and newer macOS builds). That translation layer does not carry the host's POSIX ownership metadata into the guest. Instead it presents mounted files under a default identity — historically root:root, or a fixed 1000 mapping depending on the sharing implementation — inside the VM. A container process running as a different, non-root UID then cannot write into a directory the VM presents as owned by someone else, producing EACCES.

Two facts make this stubborn. First, host-native chmod/chown cannot fix it, because the ownership you see inside the container is synthesised at the translation boundary, not read from the host inode. You can sudo chown the file on your Mac all day and the container will still see the VM's mapped owner. Second, running chown -R inside the container fails too, because a non-root process lacks CAP_CHOWN, and even as root the change does not persist back across the bind for a fresh file the host later creates. On native Linux none of this appears: the kernel shares the same inode and UID namespace, so the host UID is the container UID.

The distinction between a bind mount and a named volume matters for the fix. A bind mount (.:/app) reflects host files and inherits the translated ownership. A named or anonymous volume (/app/node_modules) lives inside the VM's own filesystem, is initialised from the image at first run, and therefore keeps whatever ownership the image directory had — which is why masking node_modules with its own volume sidesteps the translation entirely.

It is worth understanding why the "obvious" remedies fail so you do not waste a debugging session on them. Adding the container process to the root group does nothing, because the mapped files are owned by root but the directory permission bits usually deny group writes. Rebuilding with RUN chmod -R 777 /app bakes a permissive mode into the image layer, but the bind mount overlays that path at runtime and re-imposes the translated ownership, so the build-time change never takes effect for mounted files. Even the Linux kernel feature that could solve this cleanly — idmapped mounts, which remap UIDs at the mount level — is not exposed through Docker Desktop's file-sharing path, so on macOS and Windows the pragmatic answer remains to make the container process be the identity the mount already presents as writable, rather than trying to rewrite ownership after the fact.

Bind mount versus named volume ownership Comparison of how a bind mount and a named volume handle ownership across the Docker Desktop VM boundary. Bind Mount vs Named Volume Bind mount .:/app reflects host files live owner translated at boundary needs user: UID:GID pin source of EACCES Named volume /app/node_modules lives inside the VM seeded from image owner no boundary translation safe for build artifacts
Masking write-heavy paths like node_modules with a named volume keeps them off the translated bind path entirely.

Resolution

The goal is a single runtime identity shared by the host user, the container process, and any baked image directories. Work through the steps in order; each is independently verifiable.

  1. Export host credentials so Compose can interpolate them at container start. id -u and id -g print the numeric IDs your files are actually owned by:

    #!/usr/bin/env bash
    set -euo pipefail
    export UID GID
    UID="$(id -u)"
    GID="$(id -g)"
    docker compose config >/dev/null && echo "UID=$UID GID=$GID resolved"
  2. Run the service as the host identity in docker-compose.yml, and mask the dependency directory with an anonymous volume so it is never routed across the bind:

    # docker-compose.yml
    services:
      app:
        user: "${UID:-1000}:${GID:-1000}"
        volumes:
          - .:/app:cached
          - /app/node_modules
  3. On Windows/PowerShell, set the values explicitly (PowerShell has no POSIX UID), or — strongly preferred — run the whole stack from inside WSL2 where id -u behaves natively:

    $env:UID = "1000"
    $env:GID = "1000"
    docker compose config
  4. Bake a non-root user into the image so the runtime identity is stable even when no environment override is present, such as on a CI runner. Match the UID to the value your team standardises on (1000 is the conventional first non-system user on Debian and Alpine):

    # Dockerfile
    RUN addgroup -g 1000 appuser && adduser -u 1000 -G appuser -D appuser
    WORKDIR /app
    RUN chown appuser:appuser /app
    USER appuser
  5. Add an entrypoint fixup for the rare case where a directory must be writable but its UID cannot be predicted (for example a volume pre-populated by another image). Gate the chown so it only runs as root and skips otherwise, keeping the container startable under a pinned user::

    #!/usr/bin/env bash
    # entrypoint.sh
    set -euo pipefail
    if [ "$(id -u)" = "0" ]; then
      chown -R 1000:1000 /app/.cache
      exec gosu 1000:1000 "$@"
    fi
    exec "$@"
  6. Rebuild and bring the stack up so the image, the volume seeding, and the runtime override are all applied together:

    #!/usr/bin/env bash
    set -euo pipefail
    docker compose build --no-cache
    docker compose up -d --wait

Align the consistency flags here with the bind-mount configuration guidance so resolving permissions does not reintroduce a watcher polling fallback that slows hot reload.

Identity alignment sequence Four ordered stages that align the host, Compose, and image identities to a single UID and GID. Aligning One Identity 1 — export UID and GID from host 2 — set user: in compose 3 — bake USER into image 4 — rebuild and verify write
Each stage narrows host, runtime, and image ownership onto the same numeric UID and GID.

Expected Output

After the fix, the container writes as the host identity and a parity test succeeds. The run --rm form checks a fresh container so you are not testing residual state from an already-running one:

#!/usr/bin/env bash
set -euo pipefail
docker compose run --rm app sh -c 'id && touch /app/.parity_test && ls -la /app/.parity_test'
uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)
-rw-r--r-- 1 1000 1000 0 Oct 24 09:15 /app/.parity_test

The touch returns exit code 0 and the created file is owned by 1000:1000, matching the process. Cross-check on the host that the same file shows up under your own account (ls -ln .parity_test on macOS shows your 501), confirming the two sides now agree on identity rather than diverging. If the file is created but shows as owned by root on the host, the user: override did not take effect — usually because UID/GID were not exported in the shell that ran docker compose up, so Compose fell back to the 1000 default while your host account is a different number. Re-run step one in the same shell session before the up.

Extend the check into a repeatable script so the whole team validates the same way. A one-line assertion that the created file's owner equals the process owner turns "works on my machine" into a deterministic pass or fail:

#!/usr/bin/env bash
set -euo pipefail
out="$(docker compose run --rm app sh -c 'touch /app/.parity_test && stat -c %u /app/.parity_test')"
test "$out" = "$(id -u)" && echo "parity OK: $out" || { echo "parity FAIL: $out"; exit 1; }

Prevention

  • Add a make test-permissions target to onboarding and CI that runs the parity test above and fails on a non-zero exit, so a regression in the Dockerfile or Compose override is caught before it reaches a teammate's machine.
  • Use COPY --chown=1000:1000 in the Dockerfile for baked assets so build-time ownership matches the runtime user and no first-run chown is needed.
  • Maintain a strict .dockerignore so host .git and node_modules never get copied into the build context and inherit translated VM permissions.
  • Pin the UID in one place — an ARG APP_UID=1000 referenced by both the adduser line and the --chown flags — so a single edit rolls out consistently.

The decision tree below routes the most common variants of this failure to the step that fixes each one, so you are not applying every remedy blindly.

Which remedy applies A decision from the failing path type to whether you pin the user or mask the path with a named volume. Which Remedy Applies Is the failing path source code or build output? Source code (bind) pin user: UID:GID Build output mask with named volume
Route each failing path to the cheapest fix: pin identity for source code, mask generated directories with a volume.

Platform Caveats

macOS (Docker Desktop): Bind mounts cross gRPC-FUSE; the user: override plus a baked USER is the durable fix — never rely on host chmod. Enabling VirtioFS in Docker Desktop settings improves throughput but does not change the ownership behaviour. WSL2: UID/GID alignment is automatic when the container runs in the same distro; cross-distro mounts need explicit UID/GID injection, and keep the repo on the Linux filesystem (~/project, not /mnt/c/...) or every write pays the 9P translation cost and can re-surface ownership drift. Apple Silicon (ARM64): Minimal images (alpine, distroless) may lack chown/gosu; add them in a build stage or use a base that ships su-exec. Only pin platform: linux/amd64 for images without an arm64 manifest, since emulation compounds the write latency.

Rollback

#!/usr/bin/env bash
set -euo pipefail
# Remove the user: directive and node_modules volume from docker-compose.yml, then:
git checkout HEAD -- docker-compose.yml Dockerfile
docker compose down
docker compose up -d --wait

If the parity test regressed after an image change, docker compose down -v additionally clears the anonymous node_modules volume so it is re-seeded from the image on the next up, discarding any stale ownership baked into the old volume.

Frequently Asked Questions

Why does chmod 777 on the host not fix the container error?

Because the ownership the container sees is synthesised at the Docker Desktop VM boundary, not read from your host inode. chmod 777 changes host permission bits, but the bind translation still presents the file under a mapped owner, and any file the container later creates starts the cycle again. Pinning the runtime UID with user: addresses the actual mismatch instead of loosening permissions everywhere.

Should I set user: "${UID}:${GID}" or bake USER into the Dockerfile?

Do both. The Compose user: override adapts the container to whichever developer runs it, while the baked USER gives a stable fallback identity for CI runners and machines that never export UID/GID. Baking the user also ensures build-time COPY --chown targets a real account rather than a bare number.

Why mask node_modules with an anonymous volume instead of bind-mounting it?

node_modules is regenerated inside the container and is write-heavy. Routing it through the host bind pays the translation cost on thousands of small files and inherits the mapped ownership. An anonymous volume (- /app/node_modules) keeps it inside the VM filesystem, seeded from the image, so it is both faster and owned by the image user — no permission mismatch and no watcher polling penalty.

My colleague on Linux never hits this — do they need the same config?

The config is safe for them and keeps the team on one identity model, but Linux hosts do not trigger the failure because the kernel shares the UID namespace directly: the host UID is the container UID with no translation layer. Setting user: "${UID:-1000}:${GID:-1000}" simply resolves to their own IDs and produces correctly owned files, so a single Compose file works across every platform.