9. Storage and volumes
Docker · 9. Storage and volumesChapter 2 said the container’s filesystem is thrown away. Chapter 3 explained why writing to it is also slow. This chapter is about where data actually goes.
The writable layer is not storage
Section titled “The writable layer is not storage”Every container gets a thin writable layer on top of the image’s read-only layers. It behaves like a filesystem and it is not one, in three ways:
1. IT IS DELETED with the container. `docker rm` removes it. A new container from the same image starts empty again.
2. IT IS SLOW. Chapter 3's copy-up: modifying any file that came from an image layer copies the whole file first.
3. IT COUNTS AGAINST THE HOST DISK with no limit by default. A container writing logs to its own filesystem will fill /var/lib/docker and take down every other container on the machine.So: anything that must survive, and anything written frequently, goes in a mount. Docker has three kinds.
The three mount types
Section titled “The three mount types” NAMED VOLUME -v mydata:/var/lib/postgresql/data Docker manages it, in /var/lib/docker/volumes/. Survives container removal. Portable across hosts via drivers. THE DEFAULT CHOICE for data.
BIND MOUNT -v /home/me/code:/app A specific host path, mounted in. You control the location and the permissions. THE DEFAULT CHOICE for development.
TMPFS --tmpfs /tmp RAM. Never touches disk. Gone on stop. For scratch space and secrets you do not want written anywhere.The modern syntax is --mount, which is more verbose and much less ambiguous:
--mount type=volume,source=mydata,target=/data --mount type=bind,source=/home/me/code,target=/app,readonly --mount type=tmpfs,target=/tmp,tmpfs-size=64mThe old -v syntax overloads one flag for all three, distinguished by whether the source looks like a path. That leads to a specific, memorable bug:
-v /home/me/code:/app a BIND mount (source starts with /) -v mydata:/app a NAMED VOLUME -v ./code:/app a bind mount in modern Docker; in older versions a VOLUME named "./code" -v $MYPATH:/app if MYPATH is empty → "-v :/app" → error, or worse, a mount you did not intendPrefer --mount in anything scripted. -v is fine interactively.
The two behaviours that surprise people
Section titled “The two behaviours that surprise people”Named volumes seed themselves from the image — once
Section titled “Named volumes seed themselves from the image — once”When you mount an empty named volume over a directory that has content in the image, Docker copies the image’s content into the volume the first time.
FIRST RUN SUBSEQUENT RUNS
volume "web" is empty volume "web" has content ↓ ↓ image has /usr/share/nginx/html/ the volume is mounted AS IS. with index.html The image's version is hidden, ↓ even if you rebuilt the image Docker copies it into the volume with a new index.html.This is convenient and it produces a classic confusion: you change a file, rebuild the image, restart — and the old file is still served. The image did change. The volume is masking it. docker volume rm web and it works.
Bind mounts do not do this. A bind mount over a directory simply hides whatever was there. Mount an empty host directory over /usr/share/nginx/html and nginx serves nothing.
The most common instance of the bind-mount version:
docker run -v $(pwd):/app node:20 npm startYour host directory has no node_modules (or has a macOS/arm64 one). It is mounted over /app, hiding the node_modules that npm ci installed in the image. MODULE_NOT_FOUND.
The standard fix is an anonymous volume masking the masked directory:
docker run -v $(pwd):/app -v /app/node_modules node:20 npm start ^^^^^^^^^^^^^^^^^^^^ an anonymous volume at /app/node_modules, which is more specific than the bind mount at /app, so it wins — and it is seeded from the image, so the correct modules are there.Mount specificity is the rule: the longest matching path wins.
Permissions — the one everyone hits
Section titled “Permissions — the one everyone hits” docker run -v $(pwd)/data:/data -u 1000:1000 myapp → PermissionError: [Errno 13] Permission denied: '/data/out.txt'A bind mount keeps the host’s ownership. The kernel does not care what a UID is called — it compares numbers. So:
HOST CONTAINER
/home/me/data owned by the app runs as UID 999 ("appuser" UID 1000 ("me") in this image's /etc/passwd)
1000 ≠ 999 → EACCES. There is no name matching anywhere.Three fixes, in order of preference:
1. Run the container as the host user: docker run -u "$(id -u):$(id -g)" … Works well for development. The user need not exist in the image's /etc/passwd — you may see "I have no name!" in the prompt, which is cosmetic. Some tools that call getpwuid() dislike it; --user with a matching entry, or nss-wrapper, fixes that.
2. Fix ownership on the host: sudo chown -R 999:999 ./data Correct for a server. Awkward in a shared repo.
3. An entrypoint that starts as root, chowns, then drops: chown -R appuser /data && exec gosu appuser "$@" What the official Postgres image does. Needs the container to start as root, which conflicts with a read-only or non-root-enforced environment.Named volumes avoid this entirely when the volume is new: Docker creates it with the ownership of the directory it seeds from in the image. This is a real, underrated reason to prefer named volumes for anything that is not live-editing source code.
Docker Desktop on macOS and Windows hides the whole problem by translating UIDs in its VM’s filesystem layer, which is why permission bugs so often appear only when a Mac-developed setup first reaches a Linux CI runner.
Volumes in practice
Section titled “Volumes in practice” docker volume create pgdata docker volume ls docker volume inspect pgdata → Mountpoint on the host docker volume rm pgdata docker volume prune → deletes UNUSED volumesdocker volume prune deletes volumes not attached to any container — including the one holding the database you were about to come back to. It does not prompt clearly enough for the damage it can do. Name your important volumes and label them:
docker volume create --label keep=true pgdata docker volume prune --filter 'label!=keep=true'The volume’s Mountpoint is a real host directory (/var/lib/docker/volumes/pgdata/_data). You can read it directly as root, which is useful for emergencies and a bad idea as routine practice — Docker considers it internal.
Anonymous volumes
Section titled “Anonymous volumes”A VOLUME /data instruction in a Dockerfile, or -v /data on the command line, creates an anonymous volume with a random 64-hex-character name. It survives container removal unless you passed --rm or docker rm -v.
This is a quiet disk leak. Every docker run of an image with a VOLUME instruction creates another one:
docker volume ls local 0c3f8e2a19b4d5… local 3b7d1f9c4a2e8… local 9e4a7c2b8f1d3… ← 40 GB across 60 of thesedocker volume prune is the cleanup. Avoiding VOLUME in Dockerfiles you control is the prevention — document the mount point instead and let users mount deliberately.
Backups
Section titled “Backups”There is no docker volume backup. The pattern is a throwaway container that mounts both the volume and a host directory:
docker run --rm \ -v pgdata:/from \ -v "$(pwd)":/to \ alpine tar czf /to/pgdata-$(date +%F).tar.gz -C /from .Restore:
docker run --rm -v pgdata:/to -v "$(pwd)":/from \ alpine sh -c "cd /to && tar xzf /from/pgdata-2026-08-29.tar.gz"For databases, do not do this on a running container. A tarball of a live PostgreSQL data directory is a crash-consistent copy at best and a corrupt one at worst, because files are being written while tar reads them. Use the database’s own tool — pg_dump, pg_basebackup, mysqldump, mongodump — and see the PostgreSQL guide’s chapter on WAL and recovery for why filesystem-level copies of a running database need more care than they look like they do.
Should a database run in a container at all?
Section titled “Should a database run in a container at all?”Worth answering directly, because it is asked constantly and the answer has changed.
For development: absolutely. A throwaway Postgres in three lines of Compose is one of the best things about Docker. It removed an entire category of onboarding pain.
For production on a single machine: yes, with care. Use a named volume or a bind mount to a dedicated disk. Never rely on the writable layer. Set memory limits with the database’s own buffer settings in mind — chapter 2’s note about runtimes not seeing cgroup limits applies to PostgreSQL’s shared_buffers too, which you must set explicitly rather than sizing from host RAM.
For production at scale: the container is not the hard part. Storage is. A container is a process; it can be restarted anywhere. A 400 GB data directory cannot. This is exactly what the Kubernetes guide’s storage and StatefulSets chapter is about, and the honest summary there applies here too: run stateless things in containers freely, and run stateful things in containers only when you have decided what happens to the data when the machine dies.
Storage drivers
Section titled “Storage drivers”You will occasionally see overlay2, btrfs, zfs, devicemapper, vfs mentioned. The short version:
overlay2 the default, on every modern Linux. Use it. btrfs / zfs valid if the host already runs that filesystem; snapshots are cheaper. devicemapper deprecated. If you are on it, you are on an old CentOS 7 setup and the performance is bad. vfs no union filesystem at all — a full copy per layer. Correct but enormously wasteful. Used in nested or unusual environments where nothing else works.docker info | grep "Storage Driver" tells you which you have. If it says anything other than overlay2, that is worth investigating; if it says vfs, that alone explains a disk usage problem.
What to take from this chapter
Section titled “What to take from this chapter”- The writable layer is not storage. It is deleted with the container, slow to write due to copy-up, and unlimited against the host disk — a container logging to its own filesystem can fill
/var/lib/dockerand take down the machine. - Three mounts: named volumes (Docker-managed, the default for data), bind mounts (a specific host path, the default for development), tmpfs (RAM, for scratch and secrets). Prefer
--mountover-vin scripts. - An empty named volume is seeded from the image the first time only. After that it masks the image — which is why a rebuilt image sometimes appears not to have changed. Bind mounts never seed; they just hide.
- Mount specificity wins, which is why
-v $(pwd):/app -v /app/node_modulesis the standard fix for a bind mount hiding installed dependencies. - Permissions compare UID numbers, not names. A host directory owned by 1000 is unwritable by a container user 999. Fix with
-u "$(id -u):$(id -g)", orchownon the host, or a root entrypoint that chowns and drops withgosu. Named volumes largely avoid this, and Docker Desktop hides it — which is why it appears first on Linux CI. VOLUMEin a Dockerfile creates a new anonymous volume on every run. It is a silent disk leak.docker volume prunecleans it and will also delete the database volume you cared about — label the ones you want to keep.- Back up with a throwaway container mounting the volume and a host directory — but for databases use their own dump tools, not
taron a live data directory. - The storage driver should be
overlay2.vfscopies every layer in full and explains disk problems on its own.
Next: networking — the bridge, veth pairs, why publishing a port is a NAT rule, and why localhost inside a container is never the host.