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);tararchives.
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:
- A named volume, if the host does not need to see the files directly. Initialisation from the image sets the right owner.
- 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). - Run as the directory's owner -
-u "$(id -u):$(id -g)", the normal pattern for development bind mounts (id -uprints 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
- choose between a named volume, a bind mount and tmpfs
- explain why a bind mount "made the app disappear"
- fix Permission denied on a mount by comparing uids, without
chmod 777