Docker & ContainersVIII · StoragePermissions
Permissions and ownership — the UID/GID trap
What you'll learn
- Diagnose Permission denied errors on mounted storage
- Explain why the kernel compares numbers, not usernames
- Choose between --user, group access, an entrypoint chown, and userns-remap
- Recognise why a fresh named volume is writable and a fresh bind mount is not
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
The most common production storage issue is a container that starts
cleanly, connects to everything it needs, and then fails on the
first write to its data directory with Permission denied.
The cause is almost never exotic. It is a number.
The kernel only sees numbers
HOSTDIR=/srv/appdata
CONTAINER=app
echo '--- host view ---'
ls -lan "$HOSTDIR" | head -5
echo '--- container view ---'
docker exec "$CONTAINER" ls -lan /data | head -5
echo '--- who the container process actually is ---'
docker exec "$CONTAINER" id$ ls -lan /srv/appdata ; docker exec app ls -lan /data ; docker exec app id--- host view ---
drwxr-xr-x 2 0 0 4096 Aug 12 09:31 .
-rw-r--r-- 1 0 0 17 Aug 12 09:31 config.yml
--- container view ---
drwxr-xr-x 2 0 0 4096 Aug 12 09:31 .
-rw-r--r-- 1 0 0 17 Aug 12 09:31 config.yml
--- who the container process actually is ---
uid=1001(app) gid=1001(app) groups=1001(app)Illustrative output
There is the whole bug in three lines. The directory is owned by 0,
the process is 1001, and mode 755 gives “other” no write bit.
ls -n is the diagnostic — always pass -n, because the names lie
and the numbers do not.
Why a named volume usually works and a bind mount usually does not
This asymmetry confuses people who have used Docker for years.
Named volume, path that exists in the image. When Docker
populates an empty volume from the image’s content at that path, it
copies ownership and mode along with the files. The postgres image
ships /var/lib/postgresql/data owned by its own UID, so the volume
comes out owned by that UID, and the container can write. No
configuration, no chown, it just works.
Named volume, path that does NOT exist in the image. There is
nothing to copy, so Docker creates an empty directory owned by
root:root with mode 0755. A container running as a non-root user
cannot write to it. This is why adding a new mount point to an
existing image suddenly breaks a container that has been running as
--user 1001 for a year.
Bind mount, always. Nothing is copied and nothing is created except the directory itself. Whatever ownership the host path has is what the container gets.
IMAGE=postgres:16
MOUNTPOINT=/var/lib/postgresql/data
# What UID does the image intend to run as?
docker image inspect "$IMAGE" --format 'USER={{.Config.User}}'
# Who owns the mount point inside the image, numerically?
docker run --rm --entrypoint stat "$IMAGE" -c '%n %u:%g %a' "$MOUNTPOINT"If USER is empty the image runs as root and nothing will fail —
which is exactly why so many images do it, and exactly why you end
up with root-owned files on the host.
The fixes, worst to best
Wrong reflex: chown -R on the host
This is what everyone reaches for, and it is worth being explicit about why it is the wrong first move rather than merely inelegant.
Better: run as the UID that owns the data
If the host directory is owned by 1001, run the container as 1001.
docker run -d --name app \
--user 1001:1001 \
--mount type=bind,src=/srv/appdata,dst=/data \
myorg/app:1.4.0
--user accepts numeric IDs that need no entry in the container’s
/etc/passwd, which is what makes it work on distroless and
scratch images. The trade-off is cosmetic: ps inside the
container shows 1001 rather than a name, and any code that calls
getpwuid() and does not handle a miss will fail. Some runtimes do.
One thing --user does not do is give you the supplementary
groups the image’s user would have had. Add them explicitly:
docker run --user 1001:1001 --group-add 2000 myorg/app:1.4.0
Better: give the group access instead of the owner
When two containers with different UIDs must share one volume, chasing a single owner is a dead end. Pick a GID, put both containers in it, and make the directory group-writable and setgid so new files inherit the group:
SHARED=/srv/shared
SHARED_GID=2000
install -d -m 2775 -g "$SHARED_GID" "$SHARED"
# Both containers join the group; neither needs to own the directory
docker run -d --user 1001:1001 --group-add "$SHARED_GID" --mount type=bind,src="$SHARED",dst=/data myorg/writer:1.0.0
docker run -d --user 1002:1002 --group-add "$SHARED_GID" --mount type=bind,src="$SHARED",dst=/data myorg/reader:1.0.0Mode 2775 is the setgid bit plus group write. Without setgid, each
container’s new files land in that container’s primary group and the
other one loses access to them one file at a time — a failure that
appears days later and only for new data.
Best: decide the UID at build time and keep it everywhere
The durable fix is that the number is part of the artefact, not part of an operator’s memory.
FROM debian:12-slim
RUN groupadd -g 1001 app && useradd -u 1001 -g 1001 -m -s /usr/sbin/nologin app
# Create the mount point IN THE IMAGE with the right ownership, so an
# empty named volume inherits it instead of coming out root-owned.
RUN install -d -o 1001 -g 1001 -m 0750 /data
USER 1001:1001
services:
app:
image: myorg/app:1.4.0
user: "1001:1001"
volumes:
- app-data:/data
volumes:
app-data:
That install -d -o 1001 line is the one people leave out, and it
is what makes a fresh named volume writable on a host nobody
prepared.
User-namespace remapping changes all of this
userns-remap is the one configuration where container UIDs really
are translated. With "userns-remap": "default" in daemon.json,
the daemon creates a dockremap user, reads its subordinate range
from /etc/subuid and /etc/subgid, and maps container UID 0 to
the first host UID in that range — typically 100000.
$ cat /etc/subuid ; ls -lan /srv/appdatadockremap:100000:65536
drwxr-xr-x 2 100000 100000 4096 Aug 12 09:44 .
-rw-r--r-- 1 100000 100000 17 Aug 12 09:44 config.ymlIllustrative output
Container UID 1001 becomes host UID 101001. Every ownership decision you made without remapping is now off by 100000, and a bind mount prepared for UID 1001 is unreadable. Enabling remapping on a host with existing bind mounts is a migration, not a toggle — plan the ownership change with it.
Verification that can fail
IMAGE=myorg/app:1.4.0
HOSTDIR=/srv/appdata
RUNAS=1001:1001
docker run --rm --user "$RUNAS" --mount type=bind,src="$HOSTDIR",dst=/data --entrypoint sh "$IMAGE" -c 'touch /data/.permcheck && rm /data/.permcheck && echo WRITABLE || echo DENIED'DENIED before you deploy is a five-minute fix. DENIED in an
application log at 03:00, from a service that has already accepted
traffic, is not.
Knowledge check
Knowledge check · 5 questions
Q1. A container runs as UID 1001 and bind-mounts a host directory owned by UID 0 with mode 0755. Docker is NOT using userns-remap. What can the container do?
Q2. You add a new mount point to an existing image and attach a fresh named volume. The container has always run as `--user 1001` and now fails to write. Why?
Q3. Why is `chown -R` on the host a poor first response to Permission denied? Select all that apply.
Q4. Without user-namespace remapping, a process running as UID 1000 inside a container is UID 1000 on the host, and the container /etc/passwd plays no part in the kernel permission check.
Q5. Two containers with different UIDs must write to one shared directory. Which approach scales without coupling their deployments?
Passing score: 75%. Answers are checked in this browser.