Volumes and Bind Mounts
Keeping data beyond the life of a container: named volumes, bind mounts and tmpfs, permissions, backing up volume data, and why containers themselves should stay disposable.
Disposable containers, lasting data
Containers are meant to be thrown away. Every new image version replaces the old container with a new one, and an orchestrator replaces containers whenever a host fails (see Containers are ephemeral). That only works if nothing important lives inside the container. Files a container writes without a mount end up in its writable layer, the thin layer on top of the read-only image layers (see Running Containers), and that layer is removed with the container.
You can see this with two short-lived containers. The first one
writes a meter reading to /tmp and is then removed; a
second container from the same image does not find the file:
$ docker run --name novol alpine:3.22 sh -c 'echo KE-0002,977.1 > /tmp/readings.csv; cat /tmp/readings.csv'
$ docker rm novol
$ docker run --rm --name novol alpine:3.22 cat /tmp/readings.csv
KE-0002,977.1 novol cat: can't open '/tmp/readings.csv': No such file or directory
The three kinds of mount
A mount makes storage from outside the container appear at a path inside it, just like mounting a disk at a mount point on a normal Linux system. Docker has three kinds on Linux (a fourth, named pipes, exists only for Windows containers):
| Mount type | Where the data lives | Survives the container? | Typical use |
|---|---|---|---|
| Volume |
A folder managed by Docker, normally under
/var/lib/docker/volumes/
|
Yes, until you remove the volume | Database files, uploads, anything persistent |
| Bind mount | Any file or folder on the host that you name | Yes (it is a host file) | Configuration files, source code during development, a backup target |
| tmpfs | The host's memory | No, gone when the container stops | Scratch files, caches, files that must never hit disk |
The word "volume" means something else in an operating system (a formatted storage area, see Disks, Partitions and Volumes). In Docker it is a named piece of storage that the container engine creates, tracks and removes for you.
Named volumes
A named volume is a volume you
create and refer to by a name, such as pgdata. Docker
keeps it until you remove it explicitly, no matter how many
containers come and go. This is the normal home for a database's
files and the first choice whenever data must persist: Docker
manages the location and the permissions, the same command works on
every host, and volume drivers can place the data on network storage
instead of the local disk.
$ docker volume create meter-data
$ docker volume ls --filter name=meter-data
$ docker volume inspect meter-data
meter-data
DRIVER VOLUME NAME
local meter-data
[
{
"CreatedAt": "2026-10-06T14:21:51+02:00",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/meter-data/_data",
"Name": "meter-data",
"Options": null,
"Scope": "local"
}
]
Mountpoint is where the data really is on the host. Two
containers that use the volume one after the other see each other's
data, even though the first one no longer exists:
$ docker run --rm --name writer -v meter-data:/data alpine:3.22 sh -c 'echo "KE-0001,2026-10-06,4123.7" >> /data/readings.csv; cat /data/readings.csv'
$ docker run --rm --name writer -v meter-data:/data alpine:3.22 sh -c 'echo "KE-0001,2026-10-07,4131.2" >> /data/readings.csv; cat /data/readings.csv'
KE-0001,2026-10-06,4123.7 KE-0001,2026-10-07,4131.2
The first command printed the first line; the second printed both.
If the volume named in -v does not exist yet, Docker
creates it, so docker volume create is optional.
Two ways to write a mount
-v (or --volume) is short:
source:target[:options]. --mount is longer
but spells out every field, which makes it easier to read in scripts
and harder to get wrong:
$ docker run -d --name db-sim --mount type=volume,source=meter-data,target=/data alpine:3.22 sleep 300
$ docker inspect --format '{{json .Mounts}}' db-sim
883190cdac484a3a20f86f0f654c914d4b98dbce4462dcdee7708c3501cea131
[{"Type":"volume","Name":"meter-data","Source":"/var/lib/docker/volumes/meter-data/_data","Destination":"/data","Driver":"local","Mode":"z","RW":true,"Propagation":""}]
docker inspect (see
Debugging Containers) lists every mount of a container: its type, where it comes from,
where it appears and whether it is writable
("RW":true). It is the quickest way to answer "where
does this container keep its data?".
Anonymous volumes
An anonymous volume is a
volume without a name you chose: Docker gives it a long random ID.
You get one with -v /data (a target without a source),
and also when an image declares a VOLUME instruction
and you do not mount anything there yourself. The official
postgres image does this for its data folder, so a
PostgreSQL container started without -v still writes to
a volume, just one nobody can recognize later.
Anonymous volumes are not removed when their container is removed,
except when the container was started with --rm or is
removed with docker rm -v. They pile up quietly;
docker volume ls then shows a list of 64-character IDs
and nobody knows which one holds data that matters. Give every
volume that holds data a name.
Bind mounts
A bind mount makes an existing file or folder of the host visible inside the container. Nothing is copied: the container works on the host's own files, and changes go both ways. That is exactly what you want for a configuration file that lives in Git next to the deployment, or for source code you are editing on your laptop while the container runs it.
$ docker run -d --name portal \
-v /srv/kestrel/portal.conf:/etc/portal/portal.conf:ro \
-p 127.0.0.1:8080:8080 \
registry.example.com/kestrel/portal:1.4.2
Bind mounts are read-write by default. A container that runs as root can then create, change or delete files on the host, and the files it creates belong to root on the host too (see Permissions below). That is why a bind mount should point at the smallest folder or single file the container needs, read-only wherever possible.
A missing host path
The two ways of writing a mount behave differently when the host
path does not exist. -v silently creates it, as an
empty folder owned by root; --mount refuses and the
container does not start:
$ docker run --rm --name bind -v "$PWD/does-not-exist:/config" alpine:3.22 ls -la /config
total 8 drwxr-xr-x 2 root root 4096 Oct 6 12:22 . drwxr-xr-x 1 root root 4096 Oct 6 12:22 ..
$ docker run --rm --name bind --mount type=bind,source="$PWD/also-missing",target=/config alpine:3.22 ls /config
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /srv/kestrel/also-missing Run 'docker run --help' for more information
The first behavior hides typing errors: a misspelled path gives the
application an empty configuration folder, and the host now has a
root-owned folder with the wrong name. With --mount
the mistake is visible at once, with exit code 125 (Docker itself
failed).
What the container sees at a mount point
When a mount lands on a folder that already has content in the image, the result depends on the kind of mount:
-
An empty volume is first filled with a copy of
the image's files at that path. This is how a new database volume
receives the files the image put there. (The
volume-nocopyoption of--mountturns the copy off.) - A volume that already has data is used as it is; the image's files at that path are hidden.
- A bind mount always shows the host's content and hides the image's, even when the host folder is empty.
- A tmpfs mount starts empty.
The rules fit in a few lines of Python. The image has two files in
/var/lib/meter; each case mounts something different
there:
def mounted_view(image_files, kind, source=None):
"""What the container sees at the mount target."""
if kind == "none":
return sorted(image_files) # just the image content
if kind == "tmpfs":
return [] # a fresh, empty file system in memory
if kind == "volume" and not source: # an empty volume is filled from the image
source.update(image_files)
return sorted(source) # bind mounts and filled volumes hide the image
image_dir = {"tariffs.csv", "README.txt"} # /var/lib/meter inside the image
cases = [
("no mount", "none", None),
("new, empty named volume", "volume", set()),
("volume with earlier data", "volume", {"readings.csv"}),
("bind mount, empty host folder", "bind", set()),
("bind mount, host config folder", "bind", {"tariff.conf"}),
("tmpfs mount", "tmpfs", None),
]
for label, kind, source in cases:
print(f"{label:31} {mounted_view(image_dir, kind, source)}")
no mount ['README.txt', 'tariffs.csv'] new, empty named volume ['README.txt', 'tariffs.csv'] volume with earlier data ['readings.csv'] bind mount, empty host folder [] bind mount, host config folder ['tariff.conf'] tmpfs mount []
The copy happens only once, into an empty volume. If a new image version ships changed files at that path, a volume that already has data keeps the old ones: a common surprise after an upgrade.
Read-only mounts and tmpfs
Read-only mounts
A read-only mount lets the
container read the data but not change it: add :ro to a
-v mount, or readonly to a
--mount. A reporting container that only reads meter
readings gets them read-only, so a bug cannot damage them:
$ docker run --rm --name reader -v meter-data:/data:ro alpine:3.22 sh -c 'cat /data/readings.csv; echo tamper >> /data/readings.csv'
KE-0001,2026-10-06,4123.7 KE-0001,2026-10-07,4131.2 sh: can't create /data/readings.csv: Read-only file system
Reading works; writing fails with "Read-only file system" and exit
code 1. Use :ro for every configuration file,
certificate and data set the container only consumes. The same idea
applied to the container's own file system is
--read-only, described in
Container Security.
tmpfs mounts
A tmpfs mount is a file system that lives only in the host's RAM. It is fast, it never touches the disk, and it disappears when the container stops. Use it for scratch space and for short-lived files you would rather not have on disk at all:
$ docker run --rm --name tmp --tmpfs /scratch alpine:3.22 sh -c 'df -h /scratch'
Filesystem Size Used Available Use% Mounted on tmpfs 7.9G 0 7.9G 0% /scratch
Without a size, the tmpfs may grow to half of the host's memory
(7.9G on this 16 GB machine). Set a limit, for example
--mount type=tmpfs,target=/scratch,tmpfs-size=64m, so a
runaway process cannot fill the host's RAM.
Permissions and user IDs
Linux decides access by numeric user and group IDs, not by names (see ownership and permission bits). A container and its host share the same kernel, so the same numbers apply on both sides of a bind mount. User 10001 in the container is user 10001 on the host, whatever name each side gives it.
On the test machine, the host folder
meter-config belongs to user 1000 with mode
700 (only the owner may enter it). The Kestrel
meter-api image runs as user 10001:
$ id -u; ls -ln
$ docker run --rm --name bind --user 10001 -v "$PWD/meter-config:/config" alpine:3.22 cat /config/tariff.conf
$ docker run --rm --name bind --user 1000 -v "$PWD/meter-config:/config" alpine:3.22 cat /config/tariff.conf
1000 total 4 drwx------ 2 1000 1000 4096 Oct 6 14:22 meter-config cat: can't open '/config/tariff.conf': Permission denied tariff=example-only
Same file, same command; only the user ID differs. The check below is the one the kernel makes, written out in Python:
import stat
def can_read(uid, gids, owner, group, mode):
"""The classic Linux permission check, as the kernel does it for a bind mount."""
if uid == 0:
return True # root (with Docker's default capabilities)
if uid == owner:
return bool(mode & 0o400) # owner bits only
if group in gids:
return bool(mode & 0o040) # group bits only
return bool(mode & 0o004) # everyone else
# The host folder meter-config: owner 1000, group 1000, mode 700
owner, group, mode = 1000, 1000, 0o700
print("meter-config:", stat.filemode(stat.S_IFDIR | mode), owner, group)
for uid, gids, who in [
(10001, {0}, "USER 10001 from the image"),
(1000, {1000}, "--user 1000:1000"),
(0, {0}, "root in the container"),
]:
verdict = "allowed" if can_read(uid, gids, owner, group, mode) else "Permission denied"
print(f"uid {uid:>5} ({who}): {verdict}")
meter-config: drwx------ 1000 1000 uid 10001 (USER 10001 from the image): Permission denied uid 1000 (--user 1000:1000): allowed uid 0 (root in the container): allowed
The other direction matters as well. A container running as root writes files that belong to root on the host:
$ docker run --rm --name bind -v "$PWD/meter-config:/config" alpine:3.22 sh -c 'id; echo cache > /config/written-by-container.txt'
$ ls -ln meter-config
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video) total 8 -rw-r--r-- 1 1000 1000 20 Oct 6 14:22 tariff.conf -rw-r--r-- 1 0 0 6 Oct 6 14:22 written-by-container.txt
The fixes, from best to worst: run the container with the user ID
that owns the data (--user 1000:1000, or a matching
USER in the Dockerfile); or give the folder to the
container's user ID once with
sudo chown -R 10001:10001; or let Docker manage the
storage with a named volume, which avoids most of these conflicts.
For more on "Permission denied", see
Troubleshooting Linux.
Removing volumes, safely
Docker will not remove a volume that a container still uses, even a stopped one:
$ docker volume rm inuse
Error response from daemon: remove inuse: volume is in use - [81470ad59f07b83438157bc69f2a9621c7a9932fb1d44118942ee8afff538c2f]
The ID in brackets is the container that holds it. That protection ends the moment the container is removed. Three commands delete data without asking twice:
-
docker volume rm pgdataremoves one volume and its data. -
docker volume pruneremoves volumes no container uses. On current Docker versions it removes only unused anonymous volumes; with--allit also removes unused named volumes, such as the database volume of a stack that is stopped for maintenance. -
docker compose down -vremoves a Compose project's containers and its volumes (see Docker Compose).
Backing up volume data
A volume protects data from container removal, not from a broken
disk, a mistaken DROP TABLE or a down -v.
It needs a
backup
like any other data, stored somewhere else.
File-level backup with a helper container
Docker's documented pattern is a short-lived container that mounts
the volume (read-only) and a host folder, and writes a
tar archive from one to the other:
$ docker run --rm \
-v meter-data:/data:ro \
-v /srv/backup:/backup \
alpine:3.22 tar czf /backup/meter-data-2026-10-06.tgz -C /data .
Restoring is the same in reverse, into a new, empty volume:
$ docker volume create meter-data-restore
$ docker run --rm \
-v meter-data-restore:/data \
-v /srv/backup:/backup:ro \
alpine:3.22 tar xzf /backup/meter-data-2026-10-06.tgz -C /data
This is fine for files that nothing is changing at that moment: uploads, exports, the meter-api's CSV files.
Databases need their own tool
A tar of a running PostgreSQL data folder is not a
reliable backup: the database writes to several files at once, and
the archive may catch them halfway. Either stop the database
container first, or (better, and without downtime) make a
logical backup
with the database's own tool, run inside the container:
$ docker exec kestrel-db pg_dump -U postgres -Fc kestrel > /srv/backup/kestrel-2026-10-06.dump
The dump is written to the host through the redirection, so it does not depend on the volume. Hamid Rahimi, Kestrel's database administrator, restores one of these dumps into a test container every month: a backup that was never restored is only a hope (see restore tests).
In Kubernetes, the same ideas return as PersistentVolumes and PersistentVolumeClaims; see Storage in Kubernetes.