Skip to main content
RunBook Academy

Docker & ContainersVIII · StoragePermissions

Permissions and ownership — the UID/GID trap

Intermediate⏱ ~26 min

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

Not yet marked complete on this device.

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

Read-only / Safesee both sides of the same inode
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
Read-only / Safethe numbers agree, the names do not
$ 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.

Read-only / Safecheck before you deploy
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:

Destructiveshared-group setup
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.0

Mode 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.

Read-only / Safewhat remapping looks like on disk
$ cat /etc/subuid ; ls -lan /srv/appdata
dockremap: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.yml

Illustrative 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

Read-only / Safeprove writability
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

  1. 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?

  2. 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?

  3. Q3. Why is `chown -R` on the host a poor first response to Permission denied? Select all that apply.

  4. 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.

  5. 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.