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.

One container file system with five paths. The /app folder comes from the read-only image layers. Other paths such as /tmp are in the writable layer, which is lost when the container is removed. /var/lib/postgresql/data is a mount of the named volume pgdata, /etc/portal/portal.conf is a read-only bind mount of a host file, and /scratch is a tmpfs mount in the host's memory. Container file system /app image layers, read-only /tmp … writable layer /var/lib/postgresql/data /etc/portal/portal.conf /scratch shared, never changed lost on docker rm named volume pgdata host file, bind mount :ro host memory (tmpfs)
One container, three mounts. Only the paths with an arrow keep their data outside the container.

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-nocopy option of --mount turns 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 pgdata removes one volume and its data.
  • docker volume prune removes volumes no container uses. On current Docker versions it removes only unused anonymous volumes; with --all it also removes unused named volumes, such as the database volume of a stack that is stopped for maintenance.
  • docker compose down -v removes 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.