Fixing Volume Permission Issues on macOS and Windows
Resolve UID/GID mismatches and EACCES errors in Docker volumes on macOS and Windows. Restore consistent cross-platform write access for local development.
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 deniedduringnpm install,pip install, orgo mod downloadchown: changing ownership of '/app/node_modules': Operation not permitted- Host
statshowing UID501(macOS) while the container expects1000: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
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"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_modulesOn Windows/PowerShell, set the values explicitly (PowerShell has no POSIX UID), or run from WSL2:
$env:UID = "1000" $env:GID = "1000" docker compose configBake 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 appuserRebuild 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-permissionstarget to onboarding and CI that runs the parity test above and fails on a non-zero exit. - Use
COPY --chown=1000:1000in the Dockerfile for baked assets so build-time ownership matches runtime. - Maintain a strict
.dockerignoreso host.git/node_modulesnever inherit translated VM permissions.
macOS (Docker Desktop): Bind mounts cross gRPC-FUSE; the
user:override plus a bakedUSERis the durable fix — never rely on hostchmod. WSL2: UID/GID alignment is automatic when the container runs in the same distro; cross-distro mounts need explicitUID/GIDinjection, and keep the repo on the Linux filesystem. Apple Silicon (ARM64): Minimal images (alpine,distroless) may lackchown/curl; usebusybox/coreutilsin a build stage and preferwget/nchealthchecks. Only pinplatform: linux/amd64for images without anarm64manifest.
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