Tech Digest

Guide

Why does my Docker container get permission denied on a bind mount?

A bind mount shows the container the host directory exactly as it is, owner and mode included, and the kernel checks the container process's numeric uid against them. Docker never adjusts that ownership for a bind mount, and when the host folder is missing it creates one owned by root. Find the uid the container runs as, and make the folder match it.

Last reviewed

Why does a Docker container get permission denied writing to a bind mount?

Because the process in the container runs as a numeric uid that does not own the host directory, and a bind mount keeps the host's owner and mode unchanged. If Docker created the directory because it did not exist, it belongs to root with mode 755, so any non-root container is refused. Find the uid with docker run --rm --entrypoint id <image>, then sudo chown -R <uid>:<gid> the host directory, or run the container as your own uid with user: or PUID/PGID where the image supports it. Do not use chmod 777.

Because the process inside the container runs as a numeric uid that is not allowed to write to the host directory, and a bind mount does nothing to change that. The container sees the host folder with its host owner and its host mode, and the kernel compares the process's uid against them like any other file access. Names do not matter: app in the container and alice on the host are the same account if both are uid 1000, and strangers if they are not. The most common version of the bug is a folder that did not exist before the first docker compose up: Docker created it, owned by root with mode 755, and a container running as anyone but root cannot write to it.

Two answers circulate, and both make the error go away#

The first answer is chmod -R 777 on the folder. It works, which is why it keeps being posted. It works by letting every account on the host, and any container that can see the path, read and rewrite the data. It also fixes only the folder: files the app creates inside get the app's own owner and mode, so a second container or your own account hits the same error a level down.

The second answer is "use a named volume instead". That works too, because Docker copies the image's files, owners included, into a new volume. You lose the plain folder you could browse and back up, and the problem returns the first time you bind-mount anything else.

The actual fix is one command once you know two numbers: the uid the container runs as, and the owner of the host directory.

What we saw, measured#

We ran each case on Docker Engine Engine 28.1.1 with Compose 2.35.1 on an ext4 host without SELinux. The test image was alpine:3.22 with a user app at uid 1000, a /data directory it owned holding one seed file, and USER app. Each run tried to create a file in /data.

MountHost or volume stateResult
-v /tmp/pt/b1:/data, folder did not existDocker created it, 0:0, mode 755touch: /data/ok: Permission denied
Same folder after chown 1000:10001000:1000Write works
New named volume -v ptv1:/dataDocker copied the seed file, kept 1000:1000Write works
docker volume create ptv2, then mounted emptyCopied on first mount, /data owned by the image's uidWrite works
New named volume with :nocopyVolume root 0:0, mode 755, emptyPermission denied
ptv1 reused by the same image rebuilt with uid 1500Old files still 1000:1000Permission denied
Folder 0:2000, mode 2775, container --user 1000:1000uid not in group 2000Permission denied
Same, with --group-add 2000New file 1000:2000Write works
-v /tmp/pt/b1:/data:roAny ownerRead-only file system

Two rows explain most forum threads. The first is the default experience: the folder appears owned by root. The sixth is the trap behind "it worked until I updated": a named volume is populated once, while it is empty, so if an image changes its uid between releases, the old data keeps the old owner.

The last row is not a permissions problem at all. Read-only file system means the mount carries :ro or read_only: true, and no chown fixes it.

Why a named volume works and a bind mount does not#

Docker's volumes page says that if a container "creates a new volume" and the image has files at the mount point, "Docker copies the directory's contents into the volume". In Moby's container/container_unix.go, copyExistingContents is described as copying "from the source to the destination" and ensuring "the ownership is appropriately set", and it returns without copying anything when the volume is not empty. Our pre-created volume shows "new" means empty, not newly created: docker volume create followed by a first mount was still filled.

Bind mounts get none of this. Docker's bind mount page says a bind mount over a non-empty directory leaves the image's files "obscured by the bind mount", and our seed file was indeed invisible behind an empty host folder.

The folder Docker created for you belongs to root#

Every way we tried of letting Docker create a missing bind mount folder produced root:root, mode 755:

How the mount was writtenMissing host folder
docker run -v /path:/dataCreated, 0:0
Compose short syntax ./data:/dataCreated, 0:0
Compose long syntax, type: bind, no other optionsCreated, 0:0
Compose long syntax with bind: create_host_path: falseRefused: bind source path does not exist
docker run --mount type=bind,src=/path,dst=/dataRefused, same message, exit 125

The compose reference says the short syntax creates the folder "for backward compatibility with docker-compose legacy", and that the long syntax's create_host_path "Defaults to true", so the long syntax alone does not save you. We also ran both docker run and docker compose run as an unprivileged user in the docker group, and the folders still came out owned by root: the daemon creates them, not your shell. Running Compose as your own user does not make the folders yours.

The cure is to create every bind mount folder yourself before the first start, with the owner the container needs, and to set create_host_path: false on mounts where a typo should fail loudly rather than start the app on an empty root-owned folder.

The config file that turned into a folder#

The same rule produces a stranger error when the missing path was meant to be a file. Mounting a nonexistent ./nginx.conf over /etc/nginx/nginx.conf in nginx:1.28-alpine made Docker create a directory called nginx.conf on the host, and the container refused to start:

text
error mounting "/tmp/pt/nginx.conf" to rootfs at "/etc/nginx/nginx.conf": ... not a directory: unknown: Are you trying to mount a directory onto a file (or vice-versa)? Check if the specified host path exists and is the expected type

Where the image had no file at that path, the container started and the app read a directory where it expected a config: cat: read error: Is a directory. Either way, delete the empty directory on the host, create the file, and start again.

Find the two numbers#

bash
# The uid the image starts as (empty means root)
docker inspect -f '{{.Config.User}}' example/app:2.3.1

# The uid the process really ends up with
docker run --rm --entrypoint id example/app:2.3.1
docker exec app id

# The owner and mode of the host folder
stat -c '%u:%g %a %n' /srv/app/data

Prefer the docker exec form: many images start as root and switch users in their entrypoint, so the declared USER is empty while the process is not root. A numeric uid with no name on the host is normal: ls -ln shows the numbers, which are what the kernel compares.

Three fixes, in the order to try them#

1. Give the folder to the container's uid. If the container runs as 1000:1000:

bash
sudo install -d -o 1000 -g 1000 -m 750 /srv/app/data   # new folder
sudo chown -R 1000:1000 /srv/app/data                   # existing folder

This is the right fix for any image with a fixed uid.

2. Run the container as your uid. Compose's user: "1000:1000", or --user with docker run, sets the process's uid directly. Whether an image accepts that is up to the image. The official Postgres image says it supports a "(mostly) arbitrary user" and that initdb needs the user to exist in /etc/passwd, which its entrypoint fakes with nss_wrapper. In our test postgres:18-alpine with --user 1000:1000 initialized and reached ready to accept connections on a folder owned by 1000. On a folder still owned by root it stopped at once with mkdir: can't create directory '/var/lib/postgresql/18/': Permission denied, because the entrypoint, no longer root, cannot take over a folder that root owns.

LinuxServer.io images are the exception: their docs say the images are "not yet compatible" with --user and to keep using PUID and PGID. Their base image's start script changes its internal abc user to those ids, defaulting to 911 when unset, then runs lsiown abc:abc /config. The lsiown script uses find with -maxdepth 0 unless it is given -R, so that call re-owns the /config directory and none of its contents. Change PUID on an existing install and the old files keep the old owner unless the app's own scripts chown them. Moving a service to a new machine covers the same mismatch when the new host's first user is not 1000.

3. Share a group. When both the container and your login need to write, give the folder a group that both have, and set the setgid bit so new files inherit it:

bash
sudo chown root:2000 /srv/app/data
sudo chmod 2775 /srv/app/data
yaml
services:
  app:
    user: "1000:1000"
    group_add:
      - "2000"

Without group_add, our container was refused even though the folder was group writable. With it, new files came out 1000:2000. The app's umask still decides whether those files are group writable, so check one file before relying on it.

Images that fix it themselves, and what that leaves on the host#

Some images start as root, chown their data directory and then drop privileges. The official Postgres image does this when no --user is given. On a bind mount we had chowned to 1000, it left the top folder alone, created 18/ as root, and made 18/docker, the data directory itself, uid 70 with mode 700. Uid 70 is the postgres user in the Alpine image, and on our host it had no name at all.

Your account can then no longer read the data, so a backup job running as your user fails on every database file. Back up a database with its own dump tool, as Backups that actually restore explains, and keep the folder layout for each stack in one place, which Docker Compose conventions sets out. Upgrading Postgres to a new major version in Docker has the Postgres data paths that change between 17 and 18.

When this answer stops applying#

  • SELinux. On Fedora, RHEL and their relatives, a correctly owned bind mount can still be denied by its label. The :z and :Z options relabel it; Docker's docs warn that using Z on a system directory such as /home or /usr "renders your host machine inoperable". Our host had no SELinux, so we did not test it.
  • User namespace remapping and rootless Docker. With userns-remap, Docker's docs give the example of host uid 231072 appearing as uid 0 in the container, so every uid above is shifted by that offset on the host, and "file ownership must be pre-arranged". Rootless Docker and rootless Podman also map container uids onto subuid ranges, which Docker vs Podman counts among Podman's running costs. We did not test either mode.
  • Network shares. On NFS with root squash, or an SMB mount, chown from the host or from a root entrypoint may fail outright. LinuxServer.io's script prints Permissions could not be set. This is probably because your volume mounts are remote or read-only in that case and carries on.
  • Docker Desktop. File sharing on macOS and Windows goes through a VM and has its own ownership rules; everything above is Linux Engine.

What to do next#

For each stack, run docker exec <name> id and stat -c '%u:%g %a' on every bind mount folder, and fix any mismatch with chown before reaching for anything else. Create new folders with install -d -o <uid> -g <gid> before the first docker compose up, never after. If a value you set in .env is not reaching the container either, that is a separate trap, covered in Why .env variables do not reach the container. And if the container exits with 137 instead of a permission error, start with What Docker exit code 137 means.

Questions#

Why does a named volume work when a bind mount gets permission denied?

Because Docker copies the image's directory into an empty named volume the first time it is mounted, and the copy keeps the image's owners. An image that runs as uid 1000 and owns its data directory gets a volume owned by uid 1000. A bind mount gets no copy: the container sees the host directory as it is, and a directory Docker created for you belongs to root. The copy happens only while the volume is empty, so it will not repair a volume that already holds data.

Who owns the folder Docker creates for a bind mount?

Root, with mode 755, every time. docker run -v, the compose short syntax and the compose long syntax all created the missing folder as root in our tests, including when Compose was run by an unprivileged user in the docker group, because the daemon creates it, not your shell. Only --mount type=bind and compose create_host_path: false refuse instead, with bind source path does not exist. Create the folder yourself, with the right owner, before the first start.

Should I just chmod 777 the bind mount?

No. It makes the error go away by letting every account on the host, and every other container that can see the path, read and change the data, and the files the container writes inside it still get their own owner and mode, so the problem comes back one level down. Give the directory to the container's uid with chown, or give both the container and your account a shared group, which fixes the same error with nothing opened to everyone.

What uid does my container run as?

Run docker run --rm --entrypoint id <image> to see the image's default user, or docker exec <name> id for a running container, since an entrypoint may switch users after starting as root. docker inspect -f '{{.Config.User}}' <image> shows the USER the image declares; empty means root. Then compare with stat -c '%u:%g %a' /path on the host. The names may differ between host and container; only the numbers matter.

Do PUID and PGID fix permissions on existing files?

Not by themselves. The LinuxServer.io base image's start script changes its abc user to your PUID and PGID and then runs lsiown abc:abc /config, and without -R that script stops at depth 0, so it changes the /config directory itself and nothing inside it. Some images chown further paths in their own scripts. If you change PUID on an existing install, chown the host directory to the new ids yourself.

Sources#

Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.