Fixing Volume Permission Issues on macOS and Windows
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 deniedduringnpm install,pip install, orgo mod downloadchown: changing ownership of '/app/node_modules': Operation not permittederror: could not create directory '/app/.cache': Permission denied- Host
statshowing UID501(macOS) while the container expects1000: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.
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.
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.
Export host credentials so Compose can interpolate them at container start.
id -uandid -gprint 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"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_modulesOn Windows/PowerShell, set the values explicitly (PowerShell has no POSIX UID), or — strongly preferred — run the whole stack from inside WSL2 where
id -ubehaves natively:$env:UID = "1000" $env:GID = "1000" docker compose configBake 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 (
1000is 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 appuserAdd 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
chownso it only runs as root and skips otherwise, keeping the container startable under a pinneduser::#!/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 "$@"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.
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-permissionstarget 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:1000in the Dockerfile for baked assets so build-time ownership matches the runtime user and no first-runchownis needed. - Maintain a strict
.dockerignoreso host.gitandnode_modulesnever get copied into the build context and inherit translated VM permissions. - Pin the UID in one place — an
ARG APP_UID=1000referenced by both theadduserline and the--chownflags — 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.
Platform Caveats
macOS (Docker Desktop): Bind mounts cross gRPC-FUSE; the
user:override plus a bakedUSERis the durable fix — never rely on hostchmod. 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 explicitUID/GIDinjection, 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 lackchown/gosu; add them in a build stage or use a base that shipssu-exec. Only pinplatform: linux/amd64for images without anarm64manifest, 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.