Docker Volumes and Persistent Data
Why containers lose data, and how to keep it: named volumes vs bind mounts vs tmpfs, where the data really lives, sharing and backing up volumes, and the UID pitfalls.
A container is disposable by design. The moment you understand that its writable layer dies with it, the whole topic of volumes stops being a footnote and becomes the thing that separates a toy container from something you can run a database in. This guide explains why containers lose data, the three ways Docker gives you to keep it (named volumes, bind mounts, tmpfs), where that data actually lives on disk, how to share a volume between containers, how to back one up and restore it, and the file-ownership pitfall that trips up almost everyone the first time. By the end you should be able to reason about persistence instead of guessing why your data vanished.
Why containers are ephemeral
A container's filesystem is built as a stack of read-only image layers with a single thin writable layer on top. Every file your app creates or changes at runtime lands in that writable layer, and that layer is bound to the life of the container. Remove the container and the writable layer is deleted with it. Nothing you wrote there survives.
You can see this in one sequence:
docker run --name t alpine sh -c 'echo hello > /data.txt'
docker start -a t # runs again on the SAME writable layer
docker rm t # writable layer is gone
docker run --name t alpine cat /data.txt # cat: can't open '/data.txt'
This is not a bug - it is the point. Containers are meant to be rebuilt, replaced, scaled, and thrown away. But real programs write things you care about: database files, uploaded images, caches, logs. The fix is to keep that data outside the writable layer, on storage whose lifecycle you control separately from the container. That is exactly what volumes and mounts do. They take a path inside the container and back it with storage that lives on the host, so removing the container leaves the data untouched.
Three ways to persist: volumes, bind mounts, tmpfs
Docker gives you three mount types, and picking the right one is most of the job.
- Named volumes - storage managed by Docker, living under Docker's own directory. You refer to them by name (
pgdata), not by a host path. This is the default choice for real application data. Docker creates the directory, sets it up, and keeps it out of your way. Volumes survivedocker rm, can be backed up, and work identically across machines because nothing depends on a specific host path. - Bind mounts - you map a specific directory on the host straight into the container (
/home/me/project:/app). The container sees the host's real files, live. This is what you want in development: edit code on your laptop, the running container sees the change instantly. The tradeoff is that it is tied to that exact host path and the host's file ownership, which makes it fragile for anything you ship. - tmpfs mounts - storage that lives in the host's RAM, never touching disk. It is empty when the container starts and gone when it stops. Use it for sensitive scratch data (secrets you do not want written to disk) or high-churn temporary files where you do not want disk wear. Linux only.
The rule of thumb: named volumes for data you keep, bind mounts for source code you are actively editing, tmpfs for secrets or throwaway scratch. If you find yourself bind-mounting a directory just to persist database files, reach for a named volume instead - you will avoid an entire class of permission and portability problems.
Where the data actually lives
This is the part people find murky, so let us be concrete.
A named volume lives under Docker's data root, on a standard Linux install at /var/lib/docker/volumes/<name>/_data. You can look, but you should treat it as Docker's private territory - do not reach in and edit files there by hand. On Docker Desktop (Mac and Windows) there is no such host path at all, because Docker runs inside a Linux VM; the volume lives inside that VM and /var/lib/docker on your Mac does not exist. This is precisely why named volumes are the portable choice: your app never names a host path, so it does not care where Docker keeps the bytes.
A bind mount has no mystery - the data is exactly the host directory you named, wherever that is. There is no copy and no indirection; the container is reading and writing your real files.
A tmpfs mount has no on-disk location by definition. It is a slice of RAM that disappears when the container stops.
docker volume inspect pgdata # shows "Mountpoint": the real _data path
docker volume inspect is the honest answer to "where is my data" - it prints the actual mountpoint, the driver, and the labels for any named volume.
Mounting into a container: -v and --mount
There are two syntaxes, and they do the same job with different ergonomics.
The older -v (or --volume) is a compact, positional string: source:target:options. It is quick to type and everywhere in the wild.
# Named volume: first field is a NAME (no slash) -> managed volume
docker run -v pgdata:/var/lib/postgresql/data postgres:16
# Bind mount: first field is a PATH (has a slash) -> host directory
docker run -v /home/me/project:/app node:20
# Read-only, so the container cannot modify the source
docker run -v /etc/myapp/config:/config:ro myapp
The gotcha with -v is that whether you get a named volume or a bind mount depends entirely on whether the first field looks like a path. pgdata:/data is a volume; ./pgdata:/data or /abs/pgdata:/data is a bind mount. One accidental ./ changes the meaning.
The newer --mount is verbose but explicit - key-value pairs where you state the type outright, so there is no guessing:
# Named volume
docker run --mount type=volume,source=pgdata,target=/var/lib/postgresql/data postgres:16
# Bind mount, read-only
docker run --mount type=bind,source=/home/me/project,target=/app,readonly node:20
# tmpfs in RAM
docker run --mount type=tmpfs,target=/scratch,tmpfs-size=64m myapp
Prefer --mount in scripts, Compose files, and anything you commit - the intent is unambiguous and it fails loudly if the source path does not exist (whereas -v will silently create an empty directory for a missing bind source, which is a classic "why is my config empty" surprise). Use -v for quick throwaway commands where brevity wins.
One more behavior worth knowing: when you mount an empty named volume onto a directory that already has files in the image, Docker copies the image's existing files into the volume on first use. Mount a bind mount or a non-empty volume over that same path and it just shadows what was there - no copy. That first-run copy is why docker run -v pgdata:/var/lib/postgresql/data postgres gives you an initialized database on a fresh volume.
Sharing a volume between containers
Because a named volume is independent of any one container, you can mount the same volume into several at once. They all read and write the same files. This is how you run a sidecar that ships another container's logs, or a backup job that reads a database's data directory.
docker volume create shared
# Writer
docker run -d --name writer -v shared:/data alpine \
sh -c 'while true; do date >> /data/log.txt; sleep 2; done'
# Reader sees the writer's files
docker run --rm -v shared:/data alpine tail -f /data/log.txt
You can also inherit another container's mounts wholesale with --volumes-from, which is handy for backup containers:
docker run --rm --volumes-from writer alpine ls -la /data
A word of caution: sharing a volume gives you no locking. Two containers writing the same files concurrently can corrupt data unless the application is built for it (most databases are emphatically not - never point two database containers at the same data volume). Sharing is safe when one writes and others only read, or when the workload coordinates access itself.
Backing up and restoring a volume
Named volumes have no built-in "export" button, and the data is not sitting in a convenient host directory you can just cp. The standard trick is to run a throwaway container that mounts the volume, and stream it to a tarball on the host with a bind mount.
Backup - mount the volume read-only, mount the current host directory, tar one into the other:
docker run --rm \
-v pgdata:/data:ro \
-v "$(pwd)":/backup \
alpine tar czf /backup/pgdata-backup.tar.gz -C /data .
That leaves pgdata-backup.tar.gz in your working directory. The -C /data . archives the contents of the volume without the leading /data path, which makes restore clean.
Restore - create (or empty) the target volume, then untar into it:
docker volume create pgdata-restored
docker run --rm \
-v pgdata-restored:/data \
-v "$(pwd)":/backup \
alpine tar xzf /backup/pgdata-backup.tar.gz -C /data
For databases specifically, stop the database container (or use its own dump tool like pg_dump/mysqldump) before a filesystem-level backup - copying live database files that are mid-write can produce an inconsistent snapshot. For everything else, the tar-through-a-container pattern above is reliable and portable, and it works the same on Docker Desktop where you cannot reach the volume path directly.
The ownership and permissions pitfall
This is the single most common way volumes ruin someone's afternoon. Inside the container, files are owned by whatever UID (numeric user id) the process runs as. That UID is just a number, and it means different things on the host and inside the container.
The classic failure: your image runs the app as a non-root user, say UID 1000 named app. You bind-mount a host directory that is owned by root, or by a host user with a different UID. The container process is UID 1000, the files are owned by UID 0 (or 1001, or whatever), so the app gets "permission denied" trying to write - even though it looks like it should have access.
The key mental model: permissions are matched by UID number, not by username. The name app inside the container and the name on your host are unrelated; only the numbers line up (or fail to). A file owned by UID 1000 is writable by a UID-1000 process regardless of what either side calls that user.
Real fixes, roughly in order of preference:
-
Use a named volume instead of a bind mount for application data. Docker initializes the volume with the right ownership on first use (copying the image's files and their ownership), so the mismatch usually never happens. This alone dodges most permission grief.
-
Run the container as the UID that owns the host files when you must bind-mount.
docker run --user "$(id -u):$(id -g)" ...makes the container process share your host UID/GID, so anything it writes is owned by you and it can read what you own.
docker run --rm --user "$(id -u):$(id -g)" \
-v "$(pwd)":/app -w /app node:20 npm install
-
Fix ownership on the host to match the container's UID when the image hard-codes a user. If the image runs as UID 1000,
sudo chown -R 1000:1000 ./dataon the host lines the numbers up. -
chown inside an entrypoint as a last resort - an entrypoint script running as root does
chown -R app:app /dataand then drops to the app user withgosu/su-exec. Many official images (Postgres, Redis) already do exactly this, which is another reason their named-volume path "just works."
Watch out for the reverse case too: if a container writes to a bind mount as root (UID 0), the resulting files on your host are owned by root, and you may be unable to delete them without sudo. Running as your own UID with --user avoids that.
Managing volumes: create, ls, inspect, rm, prune
The volume subcommands are small and worth knowing cold.
docker volume create pgdata # create a named volume explicitly
docker volume ls # list all volumes
docker volume ls -f dangling=true # only volumes not used by any container
docker volume inspect pgdata # driver, mountpoint, labels, options
docker volume rm pgdata # remove one volume (must be unused)
docker volume prune # remove ALL unused (dangling) volumes
A few things to internalize. You rarely need docker volume create up front - running a container with a named volume that does not exist yet creates it automatically. docker volume rm refuses to delete a volume that a container (even a stopped one) still references, which is a safety net; remove or recreate the container first.
docker volume prune is the one to respect. It deletes every volume not currently attached to a container, which is great for reclaiming space but perfectly happy to erase a database volume whose container you happened to docker rm five minutes ago. It only touches anonymous and unused volumes by default, but "unused" includes named volumes you fully intend to reuse - if no running or stopped container is holding them, they are fair game. Read what it lists before you confirm, and never wire prune -f into a script that runs anywhere near data you care about.
Anonymous volumes deserve a mention: if you mount a target without naming a source (-v /data or an image's VOLUME instruction), Docker creates a volume with a random hex name. These accumulate silently and are the usual thing prune cleans up. Prefer named volumes so your persistent data has a stable, obvious identity rather than a hash you cannot tell apart from junk.
The shape of it
A container's writable layer dies with the container, so anything you want to keep has to live in a mount whose lifecycle you own. Named volumes are the default for real data - Docker-managed, portable, and immune to the host-path and UID traps. Bind mounts put your live host files into the container, ideal for editing code but tied to a path and its ownership. tmpfs keeps things in RAM for secrets and scratch. You mount with the quick -v or the explicit --mount, and docker volume inspect tells you where the bytes truly are. Share volumes deliberately (one writer, not two databases), back them up by tarring through a throwaway container, and when writes get "permission denied" remember it is UID numbers that must match, not usernames. Keep that model and your data outlives your containers, which is the entire point of running anything stateful in Docker.