Docker · lesson 5 of 9
Intermediate
3Also part of Docker
5- + Deploying FastAPI with Docker and Kubernetes: Images, Signals, and Probes from FastAPI
- + Dockerizing Spring Boot Applications from Java
- + Bash Process Management from Bash
- + CIS Docker Benchmark Checks with docker-bench-security from Cloud Security
- + Signing Container Images with Cosign: Verify the Digest, Not the Tag from DevSecOps (AppSec in the pipeline)
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
- The
webrootvolume was mounted by a container that ran with--rmand is long gone. Is nginx’s default page still in the volume? Check with analpine:3.20container.
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.
- Bind-mount a single host file,
app.conf, read-only at/etc/app.confand 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.
- 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.