Lab 1 showed the writable layer is disposable. There are two ways to keep data past it, they behave differently, and the difference bites on exactly one thing: who owns the files.
1. A named volume
mkdir -p ~/labs/docker-data && cd ~/labs/docker-data
docker volume create labvol
docker volume inspect labvol --format '{{.Mountpoint}}'
docker run --rm -v labvol:/data alpine:3.20 sh -c 'echo persisted > /data/note'
docker run --rm -v labvol:/data alpine:3.20 cat /data/noteDocker owns the storage, somewhere under /var/lib/docker/volumes. The container writes as root and the volume is owned by root, so nothing complains.
Verify
2. A bind mount
mkdir -p ./site
echo "<h1>from the host</h1>" > ./site/index.html
docker run --rm -v "$PWD/site:/usr/share/nginx/html:ro" \
-p 8080:80 -d --name labweb nginx:alpine
sleep 2
curl -s localhost:8080
docker rm -f labwebA bind mount maps a host path in. Edits on the host appear instantly inside — which is why it is the right tool for development and the wrong one for production data. :ro makes it read-only, and a config file mounted read-only is a habit worth keeping.
Verify
3. Now break it
mkdir -p ./appdata
docker run --rm -u 1000:1000 -v "$PWD/appdata:/data" alpine:3.20 \
sh -c 'echo hello > /data/f; echo "exit=$?"'
sudo chown 9999:9999 ./appdata
docker run --rm -u 1000:1000 -v "$PWD/appdata:/data" alpine:3.20 \
sh -c 'echo hello > /data/f2 2>&1; echo "exit=$?"'The second fails. A bind mount carries the host's ownership straight through, and the container's user is just a number — uid 1000 inside is uid 1000 outside. If the host directory is owned by 9999, uid 1000 cannot write to it, and no chmod inside the container will change that.
This is the single most common container permission bug, and it only happens with bind mounts. A named volume avoids it because Docker creates the directory with the ownership the image expects.
Verify
4. Fix it three ways
sudo chown 1000:1000 ./appdata
docker run --rm -u 1000:1000 -v "$PWD/appdata:/data" alpine:3.20 \
sh -c 'echo a > /data/fix1; echo "match host ownership: exit=$?"'
sudo chown 9999:9999 ./appdata
docker run --rm -u 9999:9999 -v "$PWD/appdata:/data" alpine:3.20 \
sh -c 'echo b > /data/fix2; echo "match container user: exit=$?"'
sudo chmod 777 ./appdata
docker run --rm -u 1000:1000 -v "$PWD/appdata:/data" alpine:3.20 \
sh -c 'echo c > /data/fix3; echo "world-writable: exit=$?"'All three work and only two are acceptable. Align the host ownership to the container's user, or run as the uid that already owns the directory. chmod 777 also "works" and gives every process on the host write access to your application's data.
Verify
5. See which one survives what
docker run -d --name labdb -v labvol:/data alpine:3.20 sleep 300
docker rm -f labdb
docker volume ls | grep labvol
docker run --rm -v labvol:/data alpine:3.20 cat /data/noteThe container is gone; the volume and its contents are not. Volumes outlive containers by design, which is also how a disk quietly fills:
docker volume ls -q | wc -l
docker system df -v | head -n 5docker volume prune removes volumes no container references. Read what it proposes first — an anonymous volume holding a database that is merely stopped looks identical to garbage.
Verify
The rule
Use a named volume for data the application owns: databases, uploads, state. Use a bind mount for things the host owns: source code in development, a config file, a certificate. If you are hitting permission errors, you are almost certainly using a bind mount where a volume belongs.
Clean up
docker volume rm labvol
cd ~ && sudo rm -rf ~/labs/docker-dataWhere this goes next
Your data survives. Next: making the build that produces the image fast enough to iterate on.