Docker & ContainersVIII · StorageVolumes
Volumes — named, anonymous, lifecycle
What you'll learn
- Create and manage named volumes
- Predict what happens to existing image content under a mount point
- Understand the volume lifecycle and what protects a volume from deletion
- Back up and migrate volumes, and verify the restore
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
A volume is a directory on the host, managed by Docker, mounted into a container. Volumes are how stateful workloads (databases, message queues, file uploads) survive container removal and restart.
They are also the only Docker object whose deletion is unrecoverable. An image can be re-pulled, a container recreated from its spec, build cache rebuilt. A volume held the only copy.
Named vs anonymous
Anonymous volumes are created when you docker run -v /data
without a name. Docker generates a random 64-character identifier.
They are difficult to reference and easy to forget about.
Named volumes are created when you docker volume create pgdata
or docker run -v pgdata:/data. The name makes them referenceable
in subsequent runs.
docker volume create pgdata
docker run -d --name db -v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=REPLACE_ME postgres:16
# Reuse the same volume in another container
docker run --rm -v pgdata:/data alpine:3.20 ls /data
The distinction is not cosmetic. It decides which prune commands can reach the volume, which is covered below.
What happens to the content already there
This is the behaviour most people learn by accident, and it differs between a volume and a bind mount.
docker volume create nginx-conf-demo
# First use: the volume is empty, so the image content is copied in
docker run --rm -v nginx-conf-demo:/etc/nginx nginx:1.27 ls /etc/nginx
# Same volume, different image path: the content is now the volume's
docker run --rm -v nginx-conf-demo:/audit:ro alpine:3.20 ls /audit$ docker run --rm -v nginx-conf-demo:/audit:ro alpine:3.20 ls /auditconf.d
fastcgi_params
mime.types
modules
nginx.conf
scgi_params
uwsgi_paramsIllustrative output
Where volumes live
VOL=pgdata
docker volume inspect "$VOL"$ docker volume inspect pgdata[
{
"CreatedAt": "2026-03-04T11:22:19Z",
"Driver": "local",
"Labels": {
"com.docker.compose.project": "shop",
"com.docker.compose.volume": "pgdata"
},
"Mountpoint": "/var/lib/docker/volumes/pgdata/_data",
"Name": "pgdata",
"Options": {},
"Scope": "local"
}
]Illustrative output
Two fields earn their place here. CreatedAt is the only timestamp
Docker gives you for a volume, and it is often the difference
between “this is last month’s deployment” and “this is production”.
Labels is where Compose records the project and service that owns
the volume — the closest thing to provenance you will get, and the
reason Compose-managed volumes are so much easier to audit than
hand-made ones.
The Mountpoint is a normal directory of normal Linux files. Read
it as root for a quick look, but do not build tooling on it: for
non-local drivers it is an implementation detail, and on a host
using a plugin-backed driver reading it directly shows you an empty
directory.
Volume drivers, and what is not one
By default Docker uses the local driver. nfs, cifs, btrfs
and zfs are not volume driver names. This trips people up
constantly, because the shape of the command looks like it should
be a driver.
Network filesystems are reached by passing mount options to the local driver:
docker volume create --driver local \
--opt type=nfs \
--opt o=addr=192.0.2.10,rw,nfsvers=4.2 \
--opt device=:/srv/exports/appdata \
appdata-nfs
type, o and device are handed to mount(8) more or less
verbatim. Real third-party drivers exist and are installed as
plugins — rclone is one Docker documents — and they show up in
docker info under Plugins: Volume:.
docker info --format '{{json .Plugins.Volume}}'On a stock host that prints ["local"]. Network storage has its
own lesson; the point here is that it is the local driver doing the
work.
Volume lifecycle
A volume outlives the containers that use it. Removing a container does not remove its volumes, named or anonymous, unless you ask.
| What you run | Named volume | Anonymous volume |
|---|---|---|
docker rm CONTAINER | Kept | Kept |
docker rm -v CONTAINER | Kept | Removed |
docker run --rm ... | Kept | Removed on exit |
docker volume prune | Kept | Removed if unattached |
docker volume prune -a | Removed if unattached | Removed if unattached |
docker compose down | Kept | Kept |
docker compose down -v | Removed | Removed |
The two bold rows in the “named” column are the only ways a named volume disappears without you typing its name. Both are worth knowing by heart.
Backup, and proving the restore
A volume is a directory, so the backup is a tar. The pattern: mount the volume read-only into a throwaway container, mount a host directory for output, use standard tools.
VOL=pgdata
DEST=/srv/backups
docker run --rm -v "$VOL":/source:ro -v "$DEST":/backup alpine:3.20 tar czf "/backup/$VOL-$(date +%F).tar.gz" -C /source .Two details that are not decoration. :ro on the source means a
mistake in the tar command cannot damage the volume. And for a
database, a file-level tar of a running data directory is a
crash-consistent copy at best — stop the container first, or use
the database’s own dump tool. pg_dump exists for a reason.
Restoring is the same shape in reverse:
VOL=pgdata-restored
SRC=/srv/backups/pgdata-2026-08-12.tar.gz
docker volume create "$VOL"
docker run --rm -v "$VOL":/target -v "$(dirname "$SRC")":/backup:ro alpine:3.20 tar xzf "/backup/$(basename "$SRC")" -C /targetNow the part people skip. A restore that ran is not a restore that worked:
VOL=pgdata-restored
# 1. Is there anything in it, and is it the right order of magnitude?
docker run --rm -v "$VOL":/v:ro alpine:3.20 du -sh /v
# 2. Does it contain the marker files this workload must have?
docker run --rm -v "$VOL":/v:ro alpine:3.20 sh -c 'test -f /v/PG_VERSION && cat /v/PG_VERSION || echo MISSING'
# 3. Does the application actually start against it?
docker run --rm -e POSTGRES_PASSWORD=REPLACE_ME -v "$VOL":/var/lib/postgresql/data postgres:16 postgres --versionStep 2 is the one that fails usefully. A tar restored into the wrong
directory level — the classic -C /target versus -C /target/data
mistake — produces a volume that is the right size and completely
unusable, and only a marker-file check catches it before the
database does.
- Name every volume.
pgdata-prod, not a hex string. A named volume also survives the defaultdocker volume prune. - Label them.
docker volume create --label owner=payments --label backup=daily pgdataputs provenance where an auditor can find it. - **Back up with the source mounted
:ro.** A read-only source cannot be damaged by a bad tar command. - Restore into a new volume name. Verify it, then swap. Never restore over the live volume as the first attempt.
- Verify with a marker file, not a size. A wrong-level tar restores the right number of bytes into an unusable tree.
- Separate container cleanup from volume reclamation. Removing a container silently removes a volume protection.
Knowledge check
Knowledge check · 5 questions
Q1. You mount an EMPTY named volume at `/etc/nginx`, a path the nginx image already populates. What does the container see there?
Q2. What does `docker volume rm pgdata` do while a STOPPED container still references pgdata?
Q3. Which of these can delete a NAMED volume and its contents? Select all that apply.
Q4. `nfs` and `cifs` are `--opt type=` values handed to mount(8) by the `local` driver, not volume driver names.
Q5. A volume restore completed, and `du -sh` reports roughly the expected size. What still needs checking?
Passing score: 75%. Answers are checked in this browser.