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.

Diagnostic

Permission failures surface during package installation or runtime writes. Typical signatures:

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

Isolate the user context and directory ownership inside the running service:

#!/usr/bin/env bash
set -euo pipefail
svc=app
docker compose exec -T "$svc" id
docker compose exec -T "$svc" ls -ln /app

Expected BAD output — the directory is owned by UID 0 but the process is UID 1000:

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

Root Cause

Docker Desktop on macOS and Windows does not run Linux containers natively — it provisions a Linux VM and routes bind mounts across the host-to-VM boundary via gRPC-FUSE (macOS) or VirtioFS (Windows/WSL2). That translation layer strips POSIX ownership metadata and maps host files to root:root (or a default docker user) inside the VM. A container process running as a non-root UID then cannot write into a directory the VM presents as root-owned, producing EACCES. Host-native chmod/chown cannot fix it because the ownership is reassigned at the translation boundary, not on disk.

Resolution

  1. Export host credentials so Compose can interpolate them:

    #!/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:

    # 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 run from WSL2:

    $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 without overrides:

    # Dockerfile
    RUN addgroup -g 1000 appuser && adduser -u 1000 -G appuser -D appuser
    USER appuser
  5. Rebuild and bring the stack up:

    #!/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 so resolving permissions does not reintroduce a watcher polling fallback.

Expected Output

After the fix, the container writes as the host identity and a parity test succeeds:

#!/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

Prevention

  • Add a make test-permissions target to onboarding and CI that runs the parity test above and fails on a non-zero exit.
  • Use COPY --chown=1000:1000 in the Dockerfile for baked assets so build-time ownership matches runtime.
  • Maintain a strict .dockerignore so host .git/node_modules never inherit translated VM permissions.

macOS (Docker Desktop): Bind mounts cross gRPC-FUSE; the user: override plus a baked USER is the durable fix — never rely on host chmod. 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. Apple Silicon (ARM64): Minimal images (alpine, distroless) may lack chown/curl; use busybox/coreutils in a build stage and prefer wget/nc healthchecks. Only pin platform: linux/amd64 for images without an arm64 manifest.

Rollback

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