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.9.2
$ docker run --name lab-note alpine:3.24 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.24 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's guaranteed to disappear.

Anything you need after docker rm has to be written to a mount. Think of a database directory, uploaded files, or a cache you don't want to rebuild. A mount is 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. You'll run each one on the main lab VM (secopslog-docker). The lesson files contain site/index.html, a one-line page. You can 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.9.2
$ docker volume create lab-notes
lab-notes
$ docker run --rm -v lab-notes:/data alpine:3.24 sh -c 'echo "order 1042 shipped" > /data/note.txt'
$ docker run --rm -v lab-notes:/data alpine:3.24 cat /data/note.txt
order 1042 shipped

Both containers were removed as soon as they exited (--rm). The text survived because it was written under /data, where the volume lab-notes is mounted. You don't even need docker volume create. -v lab-notes:/data creates the volume on first use if it doesn't exist. That's convenient, and it's 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.9.2
$ docker volume inspect lab-notes
[ { "CreatedAt": "2026-10-26T12:28:49+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 26 12:28 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. Docker Desktop keeps them inside its own Linux VM, so on a Mac or Windows laptop there's 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 you're 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.9.2
$ docker run --rm --mount type=volume,src=lab-notes,dst=/data,readonly alpine:3.24 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.9.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.31-alpine
203667ba4b76b25f85af353195617c8700bdba4c91b6b16f687ad1d4ea555da0
$ 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's 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.9.2
$ docker run --rm -v lab-html:/usr/share/nginx/html nginx:1.31-alpine ls /usr/share/nginx/html
50x.html index.html
$ docker run --rm -v lab-html:/data alpine:3.24 ls /data
50x.html index.html
$ mkdir empty
$ docker run --rm -v "$PWD/empty":/usr/share/nginx/html nginx:1.31-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. It kept them, so 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's 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's mounted. The files aren't 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 doesn't exist:

ubuntu@secopslog-docker:~/lab/storage · Docker 29.9.2
$ docker run --rm -v "$PWD/uploads":/data alpine:3.24 true
$ ls -ld uploads
drwxr-xr-x 2 root root 4096 Oct 26 12:28 uploads
$ docker run --rm --mount type=bind,src="$PWD/uplaods",dst=/data alpine:3.24 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.9.2
$ docker run --rm -v "$PWD/site":/site alpine:3.24 touch /site/from-root.txt
$ docker run --rm --user 1001 -v "$PWD/site":/site alpine:3.24 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.24 touch /site/from-me.txt
$ ls -ln site
total 4 -rw-r--r-- 1 1000 1000 0 Oct 26 12:28 from-me.txt -rw-r--r-- 1 0 0 0 Oct 26 12:28 from-root.txt -rw-r--r-- 1 1000 1000 26 Oct 26 12:28 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) can't 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 couldn't 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.9.2
$ docker run --rm --tmpfs /scratch alpine:3.24 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 isn't 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.9.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. "Volumes, bind mounts and tmpfs in practice" in Docker in depth covers the rest. That includes ownership details, SELinux labels on Fedora and RHEL (:z and :Z), and how to back up and restore a volume.

Try this

Recreate lab-notes and write the note into it as before. Then leave a stopped container behind with docker run --name lab-note -v lab-notes:/data alpine:3.24 true (no --rm) and run docker volume rm lab-notes. Predict whether the removal works, check, then docker rm lab-note and try the removal again.

Takeaway

Anything that must outlive docker rm goes into a mount. The writable layer took /note.txt with it, and the next cat exited 1. For bind mounts in scripts, prefer --mount: -v quietly created a root-owned uploads directory, while the uplaods typo under --mount failed loudly with exit status 125.

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.

Related