OnCallReady

Lesson 11.22 · Docker Runtime & Networking · 16 min read

Volumes, bind mounts, tmpfs, and the UID problem

In plain words

A container is like a hotel room: when you check out, housekeeping throws away everything you left behind. If you want to keep your things, you need a locker. Docker gives you three kinds. A named volume is a locker the hotel manages for you, and it is pre-filled with whatever the room came with. A bind mount is a door straight into your own house: whatever is there shows up in the room, and it hides what the room had. A tmpfs is a whiteboard that is wiped when you leave.

In Docker, anything written outside a volume goes to the container's writable layer and is deleted with docker rm. -v pgdata:/var/lib/postgresql/data keeps the database, -v /srv/reports:/out shares a host folder, --tmpfs /tmp is memory-only scratch space.

Why this matters

"The database is empty after the redeploy." A container's own files die with it, so anything worth keeping has to live somewhere else. And the moment you share a host directory with a container, you meet "Permission denied" with no explanation. This lesson covers where data can live and the UID rule that explains the errors.

What you need to know already: users, uids and chown/chmod (4.3, 4.5); mounts - attaching a filesystem at a directory (4.18); the container's writable layer on top of the image (10.3); tar archives.

Three ways to put data somewhere

All three are mounts (4.18): something from outside appears at a path inside the container.

named volume   -v pgdata:/var/lib/postgresql/data
               Docker-managed storage, under /var/lib/docker/volumes/<name>/_data.
               Survives docker rm. The default for data.

bind mount     -v /srv/reports:/out     or   -v "$(pwd)":/app
               A host directory (or file) you choose, mapped in. For development,
               config files, host logs. Brings the host's ownership and permissions.

tmpfs          --tmpfs /tmp
               RAM only, gone when the container stops. Scratch space, secrets,
               the writable bits of a --read-only container (11.29).

-v SOURCE:TARGET: if SOURCE starts with / (or .), it is a host path - a bind mount; if it is a plain name, it is a named volume. TARGET is the path inside the container.

Everything written anywhere else goes to the container's writable layer and is destroyed with the container. A database without a volume loses its data on the first docker rm.

$ docker volume create pgdata
$ docker run -d --name pg -e POSTGRES_PASSWORD=x -v pgdata:/var/lib/postgresql/data postgres:16
$ docker volume inspect pgdata
[
    {
        "CreatedAt": "2026-09-23T10:31:02Z",
        "Driver": "local",
        "Mountpoint": "/var/lib/docker/volumes/pgdata/_data",
        "Name": "pgdata",
        "Scope": "local"
    }
]
$ sudo ls /var/lib/docker/volumes/pgdata/_data
PG_VERSION

docker volume create makes one (docker run -v name:... also creates it on first use); docker volume inspect shows its details. Mountpoint is where the data really is on the host; Driver local = a plain directory on this disk. The volume is an ordinary directory. /var/lib/docker is mode 0710, owned by root, which is why you need sudo to look.

Named volumes are initialised from the image; bind mounts are not

The first time an empty named volume is mounted over a path that has content in the image, Docker copies that content - with its ownership - into the volume. The postgres image's data directory belongs to uid 999; a fresh volume mounted there belongs to 999 too, and postgres can write.

A bind mount does no such thing. It simply hides whatever the image had at that path (like mounting a disk over a non-empty directory in 4.18):

$ docker run --rm -v "$(pwd)":/app orders:slim
Error: Unable to access jarfile app.jar

The Java app file (app.jar) is in the image at /app/app.jar. The bind mount put your current directory ($(pwd)) on top of /app, and the jar is not in your current directory. Mount to a subdirectory (-v "$(pwd)/config":/app/config), not over the application.

-v /does/not/exist:/data silently creates /does/not/exist on the host, owned by root. The longer --mount syntax refuses instead - the reason to prefer it in scripts:

docker run --mount type=bind,source=/does/not/exist,target=/data alpine:3.20
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /does/not/exist

The permission problem, which has no magic fix

$ sudo mkdir -p /srv/batch-out
$ ls -lnd /srv/batch-out
drwxr-xr-x 2 0 0 4096 Sep 23 10:00 /srv/batch-out
$ docker run --rm -u 1000:1000 -v /srv/batch-out:/out alpine:3.20 touch /out/report.csv
touch: /out/report.csv: Permission denied

ls -l plus -n (numeric: print uid/gid numbers, not names) and -d (the directory itself, not its contents). Owner uid 0, group 0, mode 755: only root may write.

The container runs as uid 1000. The kernel does not know about "container users": without user namespaces (a kernel feature that maps uids inside to different uids outside, 11.29), uid 1000 inside is uid 1000 outside, and the normal rules from 4.5 apply. Names can differ on both sides; numbers do not. Always compare the two numbers:

ls -lnd /host/path            numeric owner on the host
docker exec <c> id            numeric uid inside (or docker run --rm --entrypoint id <image>)

The real fixes, in order of preference:

  1. A named volume, if the host does not need to see the files directly. Initialisation from the image sets the right owner.
  2. chown the host directory to the container's uid (sudo chown 1000:1000 /srv/batch-out) - or give it a group the container is in (--group-add GID).
  3. Run as the directory's owner - -u "$(id -u):$(id -g)", the normal pattern for development bind mounts (id -u prints your own uid).

chmod 777 "works" and is the wrong answer: every process and user on the host can now write there - the opposite of why you ran the container as non-root.

The reverse problem exists too: a container running as root writes root-owned files into your bind-mounted source tree, and you need sudo to delete your own build output. -u "$(id -u):$(id -g)" fixes that as well.

Anonymous volumes pile up

Images can declare VOLUME /path in their Dockerfile (postgres, redis do). Run them without a volume of your own and Docker creates an anonymous volume - one with no name, just a 64-hex-character ID:

$ docker volume ls
DRIVER    VOLUME NAME
local     3f1e9a0c5d...8b2
local     pgdata

docker rm leaves it behind; docker rm -v or --rm on the run removes it. docker volume prune removes unused anonymous volumes only (since Docker 23); docker volume prune -a includes named ones - read the warning before typing y.

Backing up a volume

A volume is a directory, so back it up with a throwaway container that mounts it next to a bind mount of where you want the archive:

docker run --rm -v pgdata:/data -v "$(pwd)":/backup alpine:3.20 \
  tar -czf /backup/pgdata-2026-09-23.tgz -C /data .

tar -czf FILE -C DIR .: create (c) a gzip-compressed (z) archive file (f) of everything in DIR. For a running database, a file copy is only crash-consistent (like pulling the power: files may be caught mid-write); use the database's own dump tool (pg_dump for postgres) for a real backup.

Data and restarts

A container that restarts keeps its volume, a container you rm and re-run with the same volume name gets its data back, and a typo in the volume name gives you a new, empty one - "the database is empty after the redeploy" is usually exactly that.

What you can now do

Why it helps

"The database is empty after the redeploy" is a real incident, and it is almost always a missing volume, a typo in the volume name, or someone running docker compose down -v out of habit. Knowing where data actually lives prevents it and makes the recovery obvious.

The UID problem is the other daily one: a batch container that fails with Permission denied on a bind-mounted directory, or a CI job that leaves root-owned files in the workspace that nobody can delete. Comparing ls -lnd on the host with id in the container gives the answer in seconds, and the right fix is never chmod 777. The same questions return in every security review of what a container can write, and on every platform that runs containers with persistent storage.

Commands in this lesson

docker ls mkdir

FAQ

What is the difference between a named volume and a bind mount?

A named volume is managed by Docker, stored under /var/lib/docker/volumes/<name>/_data, and survives docker rm. When it is empty and mounted over a path that has content in the image, Docker copies that content, with ownership, into it. A bind mount maps an existing host path you choose into the container, with the host's ownership and permissions, and hides whatever the image had at that path. Use volumes for data, bind mounts for development code, config files and host directories.

Why did my bind mount make the app disappear?

A bind mount replaces the directory it is mounted over. -v "$(pwd)":/app puts your current directory on top of the image's /app, so the app.jar built into the image is hidden and the app fails with "Unable to access jarfile". Mount into a subdirectory instead, like -v "$(pwd)/config":/app/config, or mount single files. Named volumes behave differently only when they are empty: then they are filled from the image.

Why do I get Permission denied writing to a bind mount?

Without user namespaces, the container's uid is the host's uid, and the kernel applies normal file permissions. A container running as 1000 cannot write into a host directory owned by root with mode 755. Compare ls -lnd /host/path with id inside the container. Fix it by using a named volume, chowning the directory to the container's uid or a shared group, or running the container as the directory's owner with -u. Do not use chmod 777.

Why does docker volume ls show volumes with long hex names?

Those are anonymous volumes. Images like postgres and redis declare VOLUME for their data path, and if you run them without mounting a volume there, Docker creates an unnamed one. docker rm leaves it behind; docker rm -v or --rm removes it. Since Docker 23, docker volume prune only removes unused anonymous volumes; docker volume prune -a also removes unused named ones, which can mean real data.

Is copying a volume directory a good database backup?

For a stopped database it is fine. For a running one, a file copy is at best crash-consistent: the files may be caught mid-write, like after a power cut, and recovery is not guaranteed for every engine or setup. Use the database's own tool, pg_dump or pg_basebackup for postgres, for a real backup. The tar-in-a-throwaway-container pattern is still useful for volumes of static data or a stopped service.

In an interview Junior

How do you persist data from a container?

Anything written to the container's writable layer is destroyed with the container. Put data in a mount:

The permission trap: without user namespaces, uid 1000 inside is uid 1000 outside. Compare ls -lnd /host/path with docker exec C id, then chown the host directory to the container's uid or run with -u "$(id -u):$(id -g)" - never chmod 777.

And a typo in the volume name gives you a new, empty volume: "the database is empty after the redeploy".

Also asked: A non-root container cannot write to a bind-mounted directory. How do you fix it? · What is the difference between a named volume and a bind mount? · How do you back up a Docker volume?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.