Skip to main content
Docker · lesson 5 of 9
Docker intermediate Lesson 5 of 9

Docker Volumes and Storage

Keep data beyond a container's life with named volumes, bind mounts and tmpfs: sharing, read-only mounts, backup and restore, and permission fixes.

By SkillByExample · Updated

A container’s writable layer dies with the container. Volumes, bind mounts and tmpfs mounts put data somewhere else. Every example uses alpine:3.20 or nginx:1.27-alpine from a scratch directory.

Data in a container dies with it; a volume does not

docker run --rm alpine:3.20 sh -c 'echo saved > /data.txt'
docker run --rm alpine:3.20 cat /data.txt
docker volume create appdata
docker run --rm -v appdata:/data alpine:3.20 sh -c 'echo "written at $(date +%T)" > /data/note.txt'
docker run --rm -v appdata:/data alpine:3.20 cat /data/note.txt
docker volume ls
docker volume inspect --format '{{.Driver}} {{.Mountpoint}}' appdata
$ docker run --rm alpine:3.20 sh -c 'echo saved > /data.txt'
$ docker run --rm alpine:3.20 cat /data.txt
cat: can't open '/data.txt': No such file or directory
$ docker volume create appdata
appdata
$ docker run --rm -v appdata:/data alpine:3.20 sh -c 'echo "written at $(date +%T)" > /data/note.txt'
$ docker run --rm -v appdata:/data alpine:3.20 cat /data/note.txt
written at 23:38:15
$ docker volume ls
DRIVER    VOLUME NAME
local     appdata
$ docker volume inspect --format '{{.Driver}} {{.Mountpoint}}' appdata
local /var/lib/docker/volumes/appdata/_data

The first file lived in a container’s writable layer, and --rm deleted it. The second went into the named volume appdata, a directory Docker manages under /var/lib/docker/volumes. A brand-new container mounting the same volume sees the file. Volumes have their own lifecycle: docker rm never deletes a named volume, only docker volume rm or prune does.

Volume versus bind mount: what an empty mount does

docker run --rm -v webroot:/usr/share/nginx/html nginx:1.27-alpine ls /usr/share/nginx/html
mkdir -p site
docker run --rm -v "$PWD/site":/usr/share/nginx/html nginx:1.27-alpine ls /usr/share/nginx/html
$ docker run --rm -v webroot:/usr/share/nginx/html nginx:1.27-alpine ls /usr/share/nginx/html
50x.html
index.html
$ mkdir -p site
$ docker run --rm -v "$PWD/site":/usr/share/nginx/html nginx:1.27-alpine ls /usr/share/nginx/html

If -v gets a name, it is a volume; if it gets a path (starting with / or ./), it is a bind mount of that host directory. When an empty named volume is first mounted, Docker copies the image’s existing files into it — so webroot now holds nginx’s default pages. A bind mount does no copying: the empty site directory simply hides what the image had at that path, and ls prints nothing.

Bind mounts for live editing, read-only

echo '<h1>version 1</h1>' > site/index.html
docker run -d --name dev -p 8081:80 -v "$PWD/site":/usr/share/nginx/html:ro nginx:1.27-alpine
docker exec dev cat /usr/share/nginx/html/index.html
echo '<h1>version 2</h1>' > site/index.html
curl -s localhost:8081
docker exec dev sh -c 'echo hacked > /usr/share/nginx/html/index.html'
docker rm -f dev
$ echo '<h1>version 1</h1>' > site/index.html
$ docker run -d --name dev -p 8081:80 -v "$PWD/site":/usr/share/nginx/html:ro nginx:1.27-alpine
fd497f3dd8fb1051f085a4d0dd5d5b3e1b84b43ef907092746e3cfb0c22c53d6
$ docker exec dev cat /usr/share/nginx/html/index.html
<h1>version 1</h1>
$ echo '<h1>version 2</h1>' > site/index.html
$ curl -s localhost:8081
<h1>version 2</h1>
$ docker exec dev sh -c 'echo hacked > /usr/share/nginx/html/index.html'
sh: can't create /usr/share/nginx/html/index.html: Read-only file system
$ docker rm -f dev
dev

A bind mount is the same directory seen from two places, so an edit on the host is served immediately — no rebuild, no restart. That is the development workflow. The :ro suffix makes the mount read-only inside the container, so a compromised or buggy process cannot change it, while the host can still edit freely.

tmpfs: in memory, never on disk

docker run --rm --tmpfs /scratch alpine:3.20 sh -c 'echo temp > /scratch/x; df -h /scratch | tail -1; cat /scratch/x'
$ docker run --rm --tmpfs /scratch alpine:3.20 sh -c 'echo temp > /scratch/x; df -h /scratch | tail -1; cat /scratch/x'
tmpfs                     7.9G      4.0K      7.9G   0% /scratch
temp

A tmpfs mount is backed by RAM. It is fast and leaves nothing behind when the container stops — useful for scratch files and for secrets that should never touch disk. Cap it with --tmpfs /scratch:size=64m, since it otherwise reports the host’s memory as its size.

Sharing a volume between containers

docker run --rm --mount type=volume,src=shared,dst=/out alpine:3.20 sh -c 'echo "from container A" >> /out/log'
docker run --rm --mount type=volume,src=shared,dst=/out alpine:3.20 sh -c 'echo "from container B" >> /out/log'
docker run --rm --mount type=volume,src=shared,dst=/in,readonly alpine:3.20 cat /in/log
$ docker run --rm --mount type=volume,src=shared,dst=/out alpine:3.20 sh -c 'echo "from container A" >> /out/log'
$ docker run --rm --mount type=volume,src=shared,dst=/out alpine:3.20 sh -c 'echo "from container B" >> /out/log'
$ docker run --rm --mount type=volume,src=shared,dst=/in,readonly alpine:3.20 cat /in/log
from container A
from container B

--mount is the long form of -v, with explicit key=value fields; it also refuses to create a missing bind-mount source instead of silently making an empty directory. shared did not exist, so the first run created it. Two writers appended, and a read-only reader saw both lines.

Back up and restore a volume

docker run --rm -v appdata:/data:ro -v "$PWD":/backup alpine:3.20 tar czf /backup/appdata.tgz -C /data .
ls appdata.tgz
docker volume rm appdata
docker run --rm -v appdata:/data -v "$PWD":/backup:ro alpine:3.20 tar xzf /backup/appdata.tgz -C /data
docker run --rm -v appdata:/data alpine:3.20 cat /data/note.txt
$ docker run --rm -v appdata:/data:ro -v "$PWD":/backup alpine:3.20 tar czf /backup/appdata.tgz -C /data .
$ ls appdata.tgz
appdata.tgz
$ docker volume rm appdata
appdata
$ docker run --rm -v appdata:/data -v "$PWD":/backup:ro alpine:3.20 tar xzf /backup/appdata.tgz -C /data
$ docker run --rm -v appdata:/data alpine:3.20 cat /data/note.txt
written at 23:38:15

There is no docker volume backup command; the pattern is a throwaway container that mounts the volume and a host directory, then runs tar between them. The restore created a fresh appdata volume and the note came back with its original timestamp. For a live database, use the database’s own dump tool instead — copying files under a running database can capture a torn state.

Permissions on a fresh volume

FROM alpine:3.20
RUN adduser -D -u 1000 app && mkdir /data && chown app:app /data
USER app
$ docker run --rm -u 1000 -v rootvol:/data alpine:3.20 touch /data/f
touch: /data/f: Permission denied
$ docker build -q -t perm-demo .
sha256:18d366b8e1363c7b7acf1fec9a0163fbc7973d491c500618045693c371ef493a
$ docker run --rm -v appvol:/data perm-demo sh -c 'touch /data/f && ls -ln /data'
total 0
-rw-r--r--    1 1000     1000             0 Oct  7 23:38 f
$ docker volume rm rootvol appvol
rootvol
appvol

A new volume mounted where the image has nothing is owned by root, so a non-root user (-u 1000) cannot write to it. When the image already contains /data owned by app, Docker copies that ownership into the empty volume on first mount, and the non-root process can write. Bind mounts get no such help: they keep the host directory’s ownership, which you fix on the host.

Practice

  1. The webroot volume was mounted by a container that ran with --rm and is long gone. Is nginx’s default page still in the volume? Check with an alpine:3.20 container.
Solution
docker run --rm -v webroot:/w alpine:3.20 ls /w
$ docker run --rm -v webroot:/w alpine:3.20 ls /w
50x.html
index.html

Yes. The copy into the empty volume happened once, at first mount, and the files stay until the volume is removed — even though no nginx container exists any more.

  1. Bind-mount a single host file, app.conf, read-only at /etc/app.conf and print it from the container.
Solution
echo 'mode=production' > app.conf
docker run --rm -v "$PWD/app.conf":/etc/app.conf:ro alpine:3.20 cat /etc/app.conf
$ echo 'mode=production' > app.conf
$ docker run --rm -v "$PWD/app.conf":/etc/app.conf:ro alpine:3.20 cat /etc/app.conf
mode=production

Bind mounts work for single files as well as directories — a common way to inject one config file.

  1. List the volumes this lesson left behind, then delete all of them in one command.
Solution
docker volume ls -q
docker volume prune -af
docker volume ls -q | wc -l
$ docker volume ls -q
appdata
shared
webroot
$ docker volume prune -af
Deleted Volumes:
appdata
webroot
shared

Total reclaimed space: 1.166kB
$ docker volume ls -q | wc -l
0

Plain docker volume prune only removes anonymous unused volumes; -a includes named ones that no container uses. Both skip volumes still attached to a container.

Frequently Asked Questions

Should I use volumes or bind mounts?
Use named volumes for data the application owns, such as database files: Docker manages their location and they work the same on every platform. Use bind mounts in development to put your source code or a config file from the host into the container, so edits show up without a rebuild.
What happens to my database data when I remove the container?
If the data directory is not on a volume, it lives in the container's writable layer and is deleted by docker rm. Mount a named volume at the data directory, for example -v pgdata:/var/lib/postgresql/data, and the data survives any number of container replacements.
Can two containers share the same volume?
Yes. Mount the same named volume in each container; they see the same files. Docker does no locking, so concurrent writers must coordinate themselves. Mounting the volume read-only in consumers is a simple way to make one container the only writer.
Why do I get Permission denied writing to a volume?
A new, empty named volume is owned by root unless the image already has the mount directory with other ownership, in which case Docker copies that ownership in. Create the directory and chown it to your app user in the Dockerfile before the USER line, or fix ownership on the host for bind mounts.