Volumes and bind mounts

Keep data when containers go away, and know which mount to use.

Beginner12 min · lesson 11 of 14
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (1 files, 1 KB): storage.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-fund/storage.tar.gz && tar -xzf storage.tar.gz, which creates ~/lab/storage/. SHA-256: 8b417cdb63558cddba39f3324154c51e8102d2448c8ea5d261221a1ccd96ef49

A container wrote one line to /note.txt. docker diff lists what the container changed compared with its image, and here it shows a single added file:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ docker run --name lab-note alpine:3.22 sh -c 'echo "order 1042 shipped" > /note.txt; cat /note.txt'
order 1042 shipped
$ docker diff lab-note
A /note.txt
$ docker rm lab-note
lab-note
$ docker run --rm alpine:3.22 cat /note.txt
cat: can't open '/note.txt': No such file or directory

A /note.txt means the file was added in the container's writable layer, the per-container scratch area described in "Images, containers and the core commands". docker rm deleted that layer with the container. The new container started from the same unchanged image, so cat exits with status 1. Nothing failed. The write simply went to the one place that is guaranteed to disappear.

Anything you need after docker rm (a database directory, uploaded files, a cache you do not want to rebuild) has to be written to a mount: storage that lives outside the container and is attached at a path inside it. Docker gives you three kinds. A named volume is storage Docker creates and manages. A bind mount is a directory of the host (the machine running the Docker daemon) made visible inside the container. A tmpfs mount is memory. This lesson runs each one on the main lab VM (secopslog-docker). The lesson files contain site/index.html, a one-line page; create it yourself if you prefer, with the content cat prints below.

Same write, three destinations
A process writes /data/x
where it lands depends on what is mounted at /data
nothing mounted
Writable layer
deleted by docker rm
-v name:/data
Named volume
kept until docker volume rm
-v /host/dir:/data
Bind mount
a host directory you own and manage
--tmpfs /data
tmpfs
memory, gone when the container stops
The decision is made when the container is created. You cannot add a mount to an existing container; you recreate it with the mount.

Named volumes

Create a volume, write through one container, read through another:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ docker volume create lab-notes
lab-notes
$ docker run --rm -v lab-notes:/data alpine:3.22 sh -c 'echo "order 1042 shipped" > /data/note.txt'
$ docker run --rm -v lab-notes:/data alpine:3.22 cat /data/note.txt
order 1042 shipped

Both containers were removed as soon as they exited (--rm), and the text survived because it was written under /data, where the volume lab-notes is mounted. You do not even need docker volume create: -v lab-notes:/data creates the volume on first use if it does not exist, which is convenient and also how a typo in a volume name silently gives you a new, empty volume.

docker volume inspect shows where the data lives:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ docker volume inspect lab-notes
[ { "CreatedAt": "2026-10-07T23:48:17+05:30", "Driver": "local", "Labels": null, "Mountpoint": "/var/lib/docker/volumes/lab-notes/_data", "Name": "lab-notes", "Options": null, "Scope": "local" } ]
$ sudo ls -l /var/lib/docker/volumes/lab-notes/_data
total 4 -rw-r--r-- 1 root root 19 Oct 7 23:48 note.txt

Mountpoint is /var/lib/docker/volumes/lab-notes/_data on a rootful Linux engine, and it stays there with the containerd image store that Docker 29 uses for images. Rootless Docker keeps volumes under ~/.local/share/docker/volumes, and Docker Desktop keeps them inside its own Linux VM, so on a Mac or Windows laptop there is no such path on your disk. The directory belongs to root, which is why the listing needed sudo. Treat that path as Docker's: read it when debugging, but change volume contents through a container. CreatedAt carries the VM's local timezone and will differ on your machine.

A mount can be read-only. The long --mount syntax spells every field out, which makes it easier to review than -v with its colon-separated fields:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ docker run --rm --mount type=volume,src=lab-notes,dst=/data,readonly alpine:3.22 sh -c 'echo edit >> /data/note.txt'
sh: can't create /data/note.txt: Read-only file system

The kernel refused the write (Read-only file system), and the shell exited 1. Mount data read-only wherever the process only needs to read it.

Bind mounts

A bind mount shares an existing host directory. Edits on either side are visible on the other immediately, which is what makes bind mounts the normal tool for local development:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ cat site/index.html
<h1>release notes v1</h1>
$ docker run -d --name lab-site -p 127.0.0.1:8080:80 --mount type=bind,src="$PWD/site",dst=/usr/share/nginx/html,readonly nginx:1.30-alpine
3677dfaeecb954f822baa01596fff06f14f18cd9d6a05797dcdc95fe37241ad7
$ curl -s http://127.0.0.1:8080/
<h1>release notes v1</h1>
$ echo '<h1>release notes v2</h1>' > site/index.html
$ curl -s http://127.0.0.1:8080/
<h1>release notes v2</h1>
$ docker rm -f lab-site
lab-site

Nginx served the file from site/ on the host. After the echo, the second request returned v2 with no rebuild or restart, because the container reads the host directory directly. readonly keeps Nginx from writing into your working tree. The -p 127.0.0.1:8080:80 part publishes the container on the VM's loopback address only, as covered in "docker run in depth".

The price of that convenience is coupling to the host. The path has to exist on every machine that runs the container, with the right contents and permissions. That is fine on your laptop and fragile on servers, so production data usually goes into named volumes and code goes into the image.

Mounting over files the image already has

The Nginx image ships two files in /usr/share/nginx/html. What you see at that path depends on what you mount there:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ docker run --rm -v lab-html:/usr/share/nginx/html nginx:1.30-alpine ls /usr/share/nginx/html
50x.html index.html
$ docker run --rm -v lab-html:/data alpine:3.22 ls /data
50x.html index.html
$ mkdir empty
$ docker run --rm -v "$PWD/empty":/usr/share/nginx/html nginx:1.30-alpine ls -A /usr/share/nginx/html

The empty named volume lab-html received a copy of the image's 50x.html and index.html the first time it was mounted, and kept them: a plain Alpine container sees the same two files in the volume. Docker populates a volume from the image this way whenever the volume is empty when it is first mounted, named or anonymous; the volume-nocopy mount option turns it off. It never happens for a bind mount. The empty bind mount produced no output at all: the host directory covers the image's files for as long as it is mounted. The files are not deleted from the image, just hidden. A web server that suddenly serves a blank page or a 403 after you added a mount is usually this.

A typo in a bind mount path

The two syntaxes disagree about a host path that does not exist:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ docker run --rm -v "$PWD/uploads":/data alpine:3.22 true
$ ls -ld uploads
drwxr-xr-x 2 root root 4096 Oct 7 23:48 uploads
$ docker run --rm --mount type=bind,src="$PWD/uplaods",dst=/data alpine:3.22 true
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /home/ubuntu/lab/storage/uplaods Run 'docker run --help' for more information

With -v, the daemon created uploads for you, as root, and the container started happily with an empty directory. With --mount, the misspelled uplaods stopped the container from being created (exit status 125, the status docker run uses for its own errors). In scripts and Compose-like automation, the error is what you want. Use --mount where a missing directory should be a failure.

Who owns the files

Files written through a bind mount keep the numeric user and group IDs of the process that wrote them. The host has no idea what user names exist inside the image:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ docker run --rm -v "$PWD/site":/site alpine:3.22 touch /site/from-root.txt
$ docker run --rm --user 1001 -v "$PWD/site":/site alpine:3.22 touch /site/from-1001.txt
touch: /site/from-1001.txt: Permission denied
$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/site":/site alpine:3.22 touch /site/from-me.txt
$ ls -ln site
total 4 -rw-r--r-- 1 1000 1000 0 Oct 7 23:48 from-me.txt -rw-r--r-- 1 0 0 0 Oct 7 23:48 from-root.txt -rw-r--r-- 1 1000 1000 26 Oct 7 23:48 index.html

ls -ln shows numbers instead of names. The Alpine container ran as root, so from-root.txt belongs to UID 0 on the host, and the ubuntu account (UID 1000) cannot edit it. It can still delete it, because deleting depends on write permission on the directory, which ubuntu owns. A process running as UID 1001 could not create a file at all, since the directory is drwxr-xr-x for UID 1000. Running the container with --user "$(id -u):$(id -g)" made the new file yours. When a containerised app gets Permission denied on a bind mount, compare the UID the process runs as (docker exec <name> id) with the owner shown by ls -ln before touching the app. Choosing that UID deliberately is the subject of "Run as non-root" in Advanced container security.

tmpfs for scratch data

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ docker run --rm --tmpfs /scratch alpine:3.22 sh -c 'grep scratch /proc/mounts'
tmpfs /scratch tmpfs rw,nosuid,nodev,noexec,relatime,inode64 0 0

/proc/mounts confirms /scratch is a tmpfs: memory-backed, private to this container, and empty again the next time the container starts. Docker mounts it noexec,nosuid,nodev by default. It never lands in the container's writable layer or in a volume, which suits temporary files and sockets. It is not a secure vault: under memory pressure the kernel can swap tmpfs pages to disk, and its size counts toward the container's memory.

Choosing, and cleaning up

Clean up the lab. Removing the containers never removed the volumes; that always takes an explicit command:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.8.2
$ rm -rf uploads empty site/from-root.txt site/from-me.txt
$ docker volume rm lab-notes lab-html
lab-notes lab-html

docker volume rm refuses to remove a volume that a container (running or stopped) still uses. docker volume prune removes only unused anonymous volumes (the unnamed ones some images declare) unless you add --all, and it asks before it deletes. Ownership details, SELinux labels on Fedora and RHEL (:z and :Z), and backing a volume up and restoring it are in "Volumes, bind mounts and tmpfs in practice" in Docker in depth.

Quick check
01Your image shop:1 saves uploaded files under /app/uploads. You run it with no mount options, upload a few files, then docker rm -f shop and start it again with the same command. Where are the uploads?
Incorrect — Names identify containers; they carry no storage. The new container is a new writable layer on top of the same image.
Incorrect — Containers never write back into their image. Only docker commit or a build creates image layers, and neither ran here.
Correct — With nothing mounted at /app/uploads, the files went into the writable layer, and docker rm removed it.
Incorrect — Docker creates a named volume only when you name one with -v name:/path or --mount.
02You bind-mount an empty host directory onto /usr/share/nginx/html and the site comes back blank. With an empty named volume at the same path, the default page appears. Why the difference?
Correct — Docker populates an empty volume with the image's files on first use. A bind mount shows exactly what is in the host directory, and here that is nothing.
Incorrect — Bind mounts are writable unless you add readonly or :ro, and read-only would not stop Nginx from reading.
Incorrect — Image layers are read-only. The files are hidden while the mount is attached and visible again without it.
Incorrect — That describes tmpfs. Named volumes live on disk under Docker's data directory.
03A deploy script starts a container with -v /srv/app/uplaods:/data (a typo) and the app starts with no uploaded files. Which change makes the mistake fail loudly next time?
Incorrect — Read-only changes write access. -v would still create the missing directory and start the container.
Incorrect — The user ID matters for permissions inside the mount; it does not stop the daemon from creating the missing path.
Incorrect — A named volume with a misspelled name is created on first use too, so the app would again start empty.
Correct — --mount refuses a bind source that does not exist, so docker run exits 125 instead of starting with an empty, root-owned directory.

Try this

Work through “Choosing, and cleaning up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from volumes and bind mounts, keep “Choosing, and cleaning up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related