Lab retested: August 30, 2026
Tested stack: Ubuntu-compatible Linux host on arm64; Docker Engine 29.7.2; containerd 2.3.3; runc 1.4.3; BusyBox 1.36 image digest sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662.
Quick answer
On a native Linux Docker host, start by comparing numeric IDs, not user names:
id
stat -c 'path=%n mode=%a uid=%u gid=%g' ./data
docker compose run --rm app sh -c 'id; stat -c "path=%n mode=%a uid=%u gid=%g" /app/data'
docker inspect <container> --format '{{json .Mounts}}'
If the container process runs as UID/GID 1001:1001 while the bind-mounted directory is owned by 2000:2000 with mode 0755, it can traverse and read the directory but cannot create a file. Run the development container as the directory owner, or grant a deliberately chosen group the minimum write access. Do not jump to chmod -R 777 or permanent root execution.
That is only one branch. A read-only mount, missing execute/search permission on a parent directory, SELinux labeling, rootless Docker, userns-remap, or a remote filesystem can produce a similar symptom.
For a second first-party Docker diagnostic pattern, see our Docker/AdGuard DNS differential test.
What a bind mount changes
A bind mount exposes a host file or directory at a container path; it is different from a Docker-managed volume. Docker documents that bind mounts are writable by default and can therefore let container processes modify host files.[1] The explicit --mount type=bind,src=...,dst=... syntax is preferable for diagnosis because the source, destination, and options are visible.[1]
On Linux, permissions are evaluated from numeric ownership and mode bits. Names such as app, node, or ubuntu are local labels; UID 1001 inside a container is still numerically different from host-owned UID 2000 when no user-namespace translation is involved. The stat command can print the numeric owner (%u), group (%g), and octal mode (%a) directly.[5]
First-party reproduction: failed and fixed branches
We built an isolated fixture under the evidence packet, changed only that disposable directory, and removed it at the end. No production container, application directory, or WordPress volume was mounted.
| Branch | Container identity | Host directory | Result |
|---|---|---|---|
| Failure | 1001:1001 |
owner 2000:2000, mode 0755 |
touch: /work/failed.txt: Permission denied |
| Fixed | 2000:2000 |
owner 2000:2000, mode 0755 |
file created as 2000:2000, content verified |
| Boundary | 2000:2000 |
same directory, bind mounted read-only | Read-only file system |
The failed branch recorded:
uid=1001 gid=1001 groups=1001
container path=/work mode=755 uid=2000 gid=2000
touch: /work/failed.txt: Permission denied
The only functional change in the fixed branch was the process identity:
uid=2000 gid=2000 groups=2000
container path=/work mode=755 uid=2000 gid=2000
created path=/work/fixed.txt mode=644 uid=2000 gid=2000 size=14
uid-gid-match
A deterministic verifier passed 17 of 17 checks, including the engine/image manifest, failed exit, fixed write, host-side ownership, read-only boundary, and fixture rollback. This proves the tested case; it does not prove that every Docker permission error is a UID/GID mismatch.
The 60-second diagnostic sequence
1. Confirm that it is really a bind mount
docker inspect <container> --format '{{json .Mounts}}'
Look for "Type":"bind", the exact source and destination, and "RW":true. Docker’s own inspection example uses those fields to confirm mount type, paths, write state, and propagation.[1]
If RW is false, stop changing ownership. The mount is read-only. Our boundary branch showed that matching 2000:2000 still failed with Read-only file system.
2. Capture the process identity
docker exec <container> id
# or, before the service starts:
docker compose run --rm app id
Use the identity of the process that actually writes. An interactive docker exec shell run as root can hide the application’s non-root failure.
3. Compare numeric ownership and every parent directory
stat -c 'path=%n mode=%a uid=%u gid=%g' ./data
namei -l ./data
A process needs execute/search permission on each parent directory to reach a child. A writable target file is not enough when one parent is inaccessible. Numeric output avoids misleading name resolution between different /etc/passwd files.
4. Check the runtime security mode
docker info --format '{{json .SecurityOptions}}'
docker context show
Our host reported built-in seccomp and cgroup namespaces, but not rootless mode or userns-remap. Docker warns that user-namespace remapping adds complexity for bind mounts and that host ownership must be pre-arranged when mounted content needs read/write access.[3] Do not copy raw UID values from this article into a remapped or rootless setup.
5. Check the filesystem and mandatory access controls
On SELinux hosts, inspect the current label and audit denial before using :z or :Z. Docker warns that these options modify host labels and that applying Z to broad system directories can make the host unusable.[1] On NFS, SMB/CIFS, or another remote filesystem, server/export rules may override local-looking mode bits.
Choose the narrowest safe fix
Fix A: run a development container as the host owner
Compose supports the service-level user field, which overrides the user used to run the container process.[2]
services:
app:
image: your-image:tested-tag
user: "${HOST_UID}:${HOST_GID}"
volumes:
- type: bind
source: ./data
target: /app/data
bind:
create_host_path: false
Compose interpolation does not execute $(id -u) inside YAML. Pass explicit values:
HOST_UID="$(id -u)" HOST_GID="$(id -g)" docker compose up
This is often the cleanest development fix, but verify that the image can run with that arbitrary identity. Some images require a home directory, a named account, or write access to paths outside the bind mount.
Fix B: preserve a fixed service UID/GID
A production image may intentionally run as a fixed non-root service account. Back up the dedicated data directory, verify the service’s numeric IDs from the exact image, and change only that directory:
docker run --rm your-image:tested-tag id
stat -c '%a %u %g %n' ./data
sudo chown --from=<old-uid>:<old-gid> -R <service-uid>:<service-gid> ./data
GNU chown changes file owner/group, accepts numeric IDs, and offers --from so a recursive change applies only when current ownership matches the expected old owner.[4] That guard is safer than an unqualified recursive ownership change, but you must still confirm the path and maintain a rollback record.
Fix C: use a dedicated shared group
When both the host user and container service must edit the same tree, create or reuse a dedicated group, give the directory that group, and grant only group write where needed. Verify default group inheritance for newly created files. Do not make unrelated users members of the group.
Fix D: use a Docker-managed volume
If operators do not need direct host-path access, a named volume can remove the host-project ownership coupling. This is an architectural change, not a magic permission bypass: back up data, confirm the image initialization behavior, migrate, and test restore before switching production.
What not to do
- Do not use
chmod -R 777. It grants write access far beyond the intended process and hides the ownership error. - Do not run the application as root permanently. A root shell may make the symptom disappear while increasing the impact of an application compromise or a mistaken path.
- Do not recursively
chown/,/home, or a path you have not verified. Bind mounts can write to host files by default.[1] - Do not assume
COPY --chownfixes a bind mount. The mount obscures pre-existing image content at the target path, so the host-mounted ownership is what the process sees.[1] - Do not change ownership when the mount is read-only. Fix the mount declaration only if the application is supposed to write.
Verification and rollback
Run a disposable write test as the same identity as the application:
docker compose run --rm app sh -c '
id
touch /app/data/.permission-test
stat -c "%a %u %g %n" /app/data/.permission-test
rm /app/data/.permission-test
'
Then verify the host sees the expected owner, start the real service, and inspect application logs. A successful touch proves directory write access, not that the application can write every required path.
Before changing ownership, save a numeric manifest:
find ./data -xdev -printf '%m %U %G %p\n' > ownership-before.txt
Rollback means reverting the Compose user change or restoring the recorded owner/group on the exact backed-up tree, then repeating the same write and application-start checks. Our lab deleted the test file, restored control of the disposable directory, removed the fixture, and recorded fixture_removed=true.
FAQ
Why does ls -l show a strange number instead of a user name?
The numeric owner exists on the inode, but the current environment has no matching name in its user database. Use ls -ln or stat -c '%u:%g' while troubleshooting.
Why can root write but my application cannot?
You tested a different identity. Capture id from the actual application process and rerun the write test with --user or Compose user set to that identity.
Will matching UID/GID always fix it?
No. Our read-only boundary failed even with matching IDs. Parent traversal, ACLs, SELinux, rootless/user namespaces, immutable flags, and remote filesystem policy are separate branches.
Should I use user: "1000:1000" everywhere?
No. Discover IDs from the actual host and image. Hard-coding 1000:1000 can fail on CI, servers, shared workstations, rootless Docker, or images that require a fixed service account.
Evidence and refresh policy
The raw failed/fixed logs, exact version manifest, deterministic verifier, rollback log, reproduction script, and checklist were captured on August 30, 2026. Retest this page after Docker Engine, image digest, host security mode, user-namespace mode, or storage backend changes.
Sources
[1] https://docs.docker.com/engine/storage/bind-mounts — Docker Docs: Bind mounts
> “Bind mounts have write access to files on the host by default.”
[2] https://docs.docker.com/reference/compose-file/services — Docker Docs: Compose services user
> “user overrides the user used to run the container process.”
[3] https://docs.docker.com/engine/security/userns-remap — Docker Docs: userns-remap
> “This re-mapping is transparent to the container, but introduces some configuration complexity in situations where the container needs access to resources on the Docker host, such as bind mounts into areas of the filesystem that the system user cannot write to.”
[4] https://man7.org/linux/man-pages/man1/chown.1.html — GNU coreutils chown(1), April 2026
> “Change the owner and/or group of each FILE to OWNER and/or GROUP.”
[5] https://man7.org/linux/man-pages/man1/stat.1.html — GNU coreutils stat(1), April 2026
> “%a permission bits in octal”
