Linux

Docker Volumes vs Bind Mounts, and the File Ownership Trap

By Nawawi JamiliPublished 8 min read

Docker gives you two ways to keep files outside a container's own writable layer: named volumes, which Docker manages, and bind mounts, which map a directory from the host. They look alike on the command line (both use -v), but they behave differently in important ways. Bind mounts also have a permission problem that almost everyone meets at least once. They can leave files that your own user cannot edit or delete.

Everything below ran on Docker 26.1.3 on a Linux host as an ordinary user (UID 1000) in the docker group, with SELinux disabled. On a host with SELinux enforcing, bind mounts also need the :z or :Z option, which I could not test here.

Named volumes: Docker owns the storage

A named volume is a directory that Docker creates and manages. You refer to it by name and do not need to know where it is stored:

terminal
$ docker volume create rg-data
rg-data
$ docker volume inspect rg-data
[
    {
        "CreatedAt": "2026-10-18T03:15:44+08:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/home/docker-data/volumes/rg-data/_data",
        "Name": "rg-data",
        "Options": null,
        "Scope": "local"
    }
]

Mountpoint is where the data actually sits. The default is under /var/lib/docker/volumes/. This machine has its Docker data root moved to /home/docker-data, which is why the path differs. Either way the directory belongs to root and my user cannot even list it. That is intended: you go through Docker, not the host filesystem.

The data stays after the container is removed:

terminal
$ docker run --name rg-writer -v rg-data:/data alpine:3.22 \
    sh -c 'echo "order 1001" > /data/orders.txt'
$ docker rm rg-writer
rg-writer
$ docker run --rm --name rg-reader -v rg-data:/data \
    alpine:3.22 cat /data/orders.txt
order 1001
$ docker volume ls --filter name=rg-
DRIVER    VOLUME NAME
local     rg-data

Removing a container never removes a named volume. Only docker volume rm or a prune does. That is what you want for a database. But it is also how a host slowly fills up with forgotten volumes, so check docker volume ls now and then.

A volume is filled from the image, a bind mount hides it

This is the behaviour difference that surprises people. The nginx:alpine image ships two files in its web root. Mount a new, empty named volume there and Docker copies the image's files into the volume first. Mount an empty host directory there and the image's files are simply hidden:

terminal
$ docker run --rm --name rg-ls nginx:alpine ls /usr/share/nginx/html
50x.html
index.html
$ docker run --rm --name rg-ls -v rg-html:/usr/share/nginx/html \
    nginx:alpine ls /usr/share/nginx/html
50x.html
index.html
$ mkdir site
$ docker run --rm --name rg-ls -v "$PWD/site":/usr/share/nginx/html \
    nginx:alpine ls /usr/share/nginx/html
$ ls site

Note that rg-html did not exist before. -v name:/path creates the volume on first use. The copy happens only while the volume is empty. Once it has content, Docker leaves it alone, so a newer image with updated files will not refresh them. That surprises people who keep application code in a volume and wonder why an upgrade changed nothing.

A bind mount is just the host directory, nothing more. Whatever is in it is what the container sees, and that is why bind mounts are the right tool for development: edit on the host, see the change in the container immediately.

One more difference. With -v, a bind source that does not exist is created for you, by the daemon, as root. The longer --mount syntax refuses instead:

terminal
$ docker run --rm --name rg-ls -v "$PWD/missing":/data alpine:3.22 true
$ ls -ldn missing
drwxr-xr-x 2 0 0 6 Oct 18 03:16 missing
$ docker run --rm --name rg-ls --mount type=bind,src="$PWD/missing2",dst=/data \
    alpine:3.22 true
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /tmp/.../demo/missing2.
See 'docker run --help'.

(I shortened the path in that error message.) A typo in a -v path gives you an empty, root-owned directory without any warning. I prefer --mount in scripts for exactly that reason.

The ownership problem

Most images run as root unless told otherwise. Root in the container is UID 0, and a bind mount writes directly into the host filesystem. So files the container creates are owned by UID 0 on the host too. There is no translation between container and host users with a default Docker setup.

terminal
$ id -u; id -g
1000
1000
$ mkdir out
$ docker run --rm --name rg-job -v "$PWD/out":/out alpine:3.22 \
    sh -c 'mkdir /out/cache && echo done > /out/report.txt && echo x > /out/cache/page.html'
$ ls -ln out
total 4
drwxr-xr-x 2 0 0 23 Oct 18 03:16 cache
-rw-r--r-- 1 0 0  5 Oct 18 03:16 report.txt
$ echo "edited" >> out/report.txt
bash: out/report.txt: Permission denied
$ rm out/cache/page.html
rm: cannot remove 'out/cache/page.html': Permission denied
$ rm -r out
rm: cannot remove 'out/cache/page.html': Permission denied

ls -ln shows numeric IDs, which is what matters here. The names ls -l prints come from the host's /etc/passwd and can mislead when the IDs mean different users inside and outside the container.

The failure pattern is worth understanding. I could not edit report.txt, because it is root's file. rm -r did delete report.txt, because deleting is permitted by write access to the directory, and out is mine. But cache is a root-owned directory, so nothing inside it can be removed. This is how build caches, node_modules and log directories become impossible to remove on developer machines and CI runners.

Without sudo, the solution is to let a container do the cleanup, since it runs as root:

terminal
$ docker run --rm --name rg-fix -v "$PWD/out":/out \
    alpine:3.22 chown -R "$(id -u):$(id -g)" /out
$ ls -ln out
total 0
drwxr-xr-x 2 1000 1000 23 Oct 18 03:16 cache
$ rm -r out

The $(id -u) is expanded by the host shell before Docker runs, so the container receives the literal 1000:1000. That works, but it only repairs the damage. It is better not to create the files as root in the first place.

Fix 1: run as your own user with --user

terminal
$ mkdir out
$ docker run --rm --name rg-job --user "$(id -u):$(id -g)" -v "$PWD/out":/out \
    alpine:3.22 sh -c 'id; echo $HOME; echo done > /out/report.txt'
uid=1000 gid=1000 groups=1000
/
$ ls -ln out
total 4
-rw-r--r-- 1 1000 1000 5 Oct 18 03:16 report.txt

The file is mine. Notice what id printed: a bare number with no name, and $HOME is /. UID 1000 does not exist in the image's /etc/passwd. Most command-line tools do not need one, but some do (anything that writes to ~/.cache, or ssh, which wants a passwd entry). Setting -e HOME=/tmp often helps. This is the quick fix for one-off jobs like running a formatter or a build tool over your source tree.

Fix 2: a matching USER in the Dockerfile

For an image you build yourself, create a real user and pass the IDs in at build time:

output
FROM alpine:3.22
ARG UID=1000
ARG GID=1000
RUN addgroup -g "$GID" app \
    && adduser -D -u "$UID" -G app app \
    && mkdir /out && chown app:app /out
USER app
WORKDIR /home/app
CMD ["sh", "-c", "id && echo built > /out/report.txt"]
terminal
$ docker build -q --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" \
    -t rg-app ../img
sha256:530aec5794a45b04a9f7631c051ed08884e6e96fdf62e1c868d71ddba22b3679
$ docker run --rm --name rg-job -v "$PWD/out":/out rg-app
uid=1000(app) gid=1000(app) groups=1000(app)
$ ls -ln out
total 4
-rw-r--r-- 1 1000 1000 6 Oct 18 03:16 report.txt

Now the user has a name and a home directory. The image is tied to one UID, which is fine on a single-developer machine and awkward on a shared build host. If the group ID already exists in the base image, addgroup fails, so check the base image's /etc/group first.

The chown on /out also helps with named volumes, because the initial copy from the image keeps ownership. A fresh volume mounted on a path the image does not have is owned by root, and the non-root user cannot write to it:

terminal
$ docker run --rm --name rg-job -v rg-appdata:/out rg-app
uid=1000(app) gid=1000(app) groups=1000(app)
$ docker run --rm --name rg-ls -v rg-appdata:/out alpine:3.22 ls -lnd /out
drwxr-xr-x    2 1000     1000            24 Oct 17 19:16 /out
$ docker run --rm --name rg-job -v rg-other:/data rg-app \
    sh -c 'ls -lnd /data; touch /data/x'
drwxr-xr-x    2 0        0                6 Oct 17 19:16 /data
touch: /data/x: Permission denied

(The container clock shows UTC and the host shows local time. That is why the hours differ.) So when an image runs as a non-root user, create and chown every directory it will mount a volume on.

Fix 3: do not let the container write at all

Often the container only needs to read. Add :ro and the question of who owns new files no longer matters:

terminal
$ printf '<h1>hello</h1>\n' > site/index.html
$ docker run --rm --name rg-ro -v "$PWD/site":/usr/share/nginx/html:ro \
    nginx:alpine \
    sh -c 'cat /usr/share/nginx/html/index.html; touch /usr/share/nginx/html/x'
<h1>hello</h1>
touch: /usr/share/nginx/html/x: Read-only file system

With --mount the same option is spelled readonly. I use read-only mounts for configuration files, certificates and static sites served by a web server container. It also protects the host from a compromised or buggy container.

Fix 4: tmpfs for scratch data

Many writes do not need to survive: temporary files, caches, PID files, sessions in a test run. A tmpfs mount lives in memory, never touches the host disk and disappears with the container. Combined with --read-only for the root filesystem, the container can write only where you allow it:

terminal
$ docker run --rm --name rg-tmp --read-only --tmpfs /tmp:size=16m alpine:3.22 \
    sh -c 'df -h /tmp; echo cache > /tmp/c && ls /tmp; touch /etc/x'
Filesystem                Size      Used Available Use% Mounted on
tmpfs                    16.0M         0     16.0M   0% /tmp
c
touch: /etc/x: Read-only file system

Set a size. The data counts against memory, and an uncontrolled process writing to an unlimited tmpfs causes a memory problem rather than a disk problem.

Which one to use

  • Named volume: database files and other state the application owns. Portable, managed by Docker, filled from the image on first use.
  • Bind mount: source code during development, and config files you edit on the host. Run the container with your UID, or mount it read-only.
  • tmpfs: anything temporary, especially with a read-only root filesystem.

The Docker volumes documentation covers drivers and backup of volumes. User namespace remapping and rootless Docker change how container UIDs map to the host. They can remove the ownership problem, but this machine runs neither, so I have not covered them here.