Phase 2 · CONTAINERS & CI/CD
Volumes & persistent data
By the end of today
- Explain why a container's filesystem is ephemeral and lost on removal
- Choose between named volumes and bind mounts for the job
- Persist a Postgres database across container restarts with a named volume
Why containers forget: volumes vs bind mounts
A container feels like a little machine, but its filesystem is not built to last. When Docker starts a container it stacks a single thin writable layer on top of the image’s read-only layers. Everything the process writes while it runs — log files, an uploaded image, rows in a database — lands in that one writable layer. And that layer is born with the container and dies with it. Stop and start the same container and your data is still there; but the moment you docker rm it, the writable layer is thrown away and every byte written since launch goes with it. This is not a bug — it is the whole point. Containers are meant to be disposable and identical, so you can kill one and start a fresh copy without a second thought. That only works if the container holds no state you care about.
So where does data that must survive go? Outside the writable layer, into storage Docker mounts into the container at a path you choose. There are two kinds.
A named volume is storage Docker itself manages. You give it a name — pgdata — and Docker creates and keeps it under its own directory on the host (/var/lib/docker/volumes/…), which you never have to think about. Mount it with -v pgdata:/var/lib/postgresql/data and the container writes there instead of into its throwaway layer. Named volumes are the right default for data the application owns — a database, an upload store — because Docker handles the location and permissions, and the volume outlives any number of containers.
A bind mount maps a specific directory on your host into the container. -v ~/site:/usr/share/nginx/html makes the container see your real ~/site folder at that path. You control exactly where it lives, which is perfect in development — mount your source code and edits show up live inside the container — but it ties the container to this host’s layout and can drag in host permission quirks. The rule: named volumes for data the app owns, bind mounts for files you own and edit.
Real world: Think of a container as a hotel room. You can pile your things on the desk while you stay, but at checkout housekeeping clears the room completely for the next guest — nothing you left behind survives. A named volume is the hotel safe: the room changes hands, the safe keeps your passport. A bind mount is a drawer that opens onto your own house next door — the room can come and go, and the drawer’s contents were never really in the room at all.
A named example makes it concrete. The official PostgreSQL image on Docker Hub declares VOLUME /var/lib/postgresql/data in its Dockerfile — the maintainers are telling you, in the image itself, exactly which directory holds state that must not live in the writable layer. Run postgres with a named volume mounted there and your tables survive every container you destroy and recreate; run it without one and the day you docker rm the container, the whole database is gone. That single mount is the difference between a toy and something you can actually restart.
Two commands round it out: docker volume create makes one ahead of time, and docker volume inspect shows you where it lives and when it was made.
Hands-On Lab
Budget about 25 minutes. Open your WSL2 Ubuntu 24.04 terminal with Docker running (you set this up across days 22–25). Type each command yourself and read every line of output — today the goal is to feel data vanish, then make it survive. Docker output carries IDs, digests and timestamps that are unique to each run, so treat those bits as “yours will differ.”
# 1. Write a file inside a container's writable layer, then delete the container.
docker run --name ephemeral ubuntu:24.04 bash -c 'echo hello > /tmp/note.txt && cat /tmp/note.txt'
docker rm ephemeral
# Output (the file printed, then the removed container's name echoes back):
# hello
# ephemeral
# 2. Start a fresh container from the same image — the file is gone. The writable layer died with the old container.
docker run --rm ubuntu:24.04 cat /tmp/note.txt
# Output:
# cat: /tmp/note.txt: No such file or directory
# 3. Create a Docker-managed named volume — it lives outside any container's life.
docker volume create appdata
# Output:
# appdata
# 4. List volumes to confirm it exists. The driver "local" means it lives on this host.
docker volume ls
# Output (you may see other volumes too):
# DRIVER VOLUME NAME
# local appdata
# 5. Inspect it — note the Mountpoint under Docker's own storage, and the local driver.
docker volume inspect appdata
# Output (the CreatedAt timestamp is yours; Mountpoint is where Docker keeps the data on the host):
# [
# {
# "CreatedAt": "2026-07-11T09:12:44Z",
# "Driver": "local",
# "Labels": null,
# "Mountpoint": "/var/lib/docker/volumes/appdata/_data",
# "Name": "appdata",
# "Options": null,
# "Scope": "local"
# }
# ]
# 6. Mount the volume at /data and write a file into it. The -v form is <volume>:<path-in-container>.
docker run --rm -v appdata:/data ubuntu:24.04 bash -c 'echo "survives" > /data/keep.txt && cat /data/keep.txt'
# Output:
# survives
# 7. The step-6 container is gone (--rm), but a NEW container on the same volume still sees the file.
docker run --rm -v appdata:/data ubuntu:24.04 cat /data/keep.txt
# Output:
# survives
# 8. A bind mount maps a HOST directory instead. Make one, mount it, and write from the container.
mkdir -p ~/site && docker run --rm -v ~/site:/out ubuntu:24.04 bash -c 'echo "<h1>hi</h1>" > /out/index.html'
ls ~/site
# Output (the file the container wrote now sits on your host):
# index.html
# 9. Persist a real database: run Postgres 17 with its data dir on a named volume.
docker run -d --name pg -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:17
# Output (a 64-char container ID — yours will differ):
# 3f9a1c7b2e8d4a6f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a
# 10. Give it a moment to start, then create a table and insert a row via psql inside the container.
sleep 5 && docker exec pg psql -U postgres -c "CREATE TABLE notes (msg text); INSERT INTO notes VALUES ('persist me');"
# Output:
# CREATE TABLE
# INSERT 0 1
# 11. Destroy the container, then start a NEW one on the SAME volume — the row survives the container's death.
docker rm -f pg && docker run -d --name pg -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:17 >/dev/null
sleep 3 && docker exec pg psql -U postgres -c "SELECT msg FROM notes;"
# Output:
# msg
# ------------
# persist me
# (1 row)
# 12. Clean up. Removing the container does NOT remove the volume — you delete data explicitly.
docker rm -f pg && docker volume rm pgdata appdata
# Output (each name echoes back as it is removed):
# pg
# pgdata
# appdata
Read the sequence back: the writable layer forgot your file the instant the container was removed (steps 1–2), a named volume remembered it across containers (steps 6–7), and a real database survived being destroyed and recreated because its data lived on pgdata, not in the container (steps 9–11) — image is the app, volume is the data.
Common Errors & Fixes
These three trip up almost everyone in their first week with volumes on Ubuntu 24.04. Read the error text slowly — learning to parse it is the actual skill.
Common error: Running any
dockercommand before the daemon is up —docker volume ls— prints:Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?Why: The
dockerCLI is only a client; it talks to a background daemon (dockerd) over a socket. If Docker Desktop or the WSL2 Docker integration is not started, there is nothing listening on that socket, so the client has no one to ask and reports the connection failure verbatim.Fix: Start the engine — launch Docker Desktop (or
sudo service docker starton a native Linux host) and wait for it to report ready, then re-run the command. Confirm it is alive withdocker info, which prints server details only when the daemon answers.How you’d spot it in prod: This exact line in a CI job means the runner has no Docker engine — a container step scheduled on a host where the daemon isn’t installed or the socket isn’t mounted. Check that the job runs on a Docker-enabled runner before blaming the image.
Common error: Trying to bind-mount a relative path with
-v—docker run -v ./data:/data ubuntu:24.04 ls /data— prints:docker: Error response from daemon: create ./data: "./data" includes invalid characters for a local volume name, only "[a-zA-Z0-9][a-zA-Z0-9_.-]" are allowed. If you intended to pass a host directory, use absolute pathWhy: With
-v, Docker decides bind-mount versus named-volume from the source. A value with a leading slash is a host path; anything else is treated as a named volume, and./datais not a legal volume name. The daemon is refusing to invent a volume from what you meant as a folder.Fix: Give an absolute host path —
docker run -v "$(pwd)/data:/data" ubuntu:24.04 ls /data— or use the explicit form that can’t be misread:--mount type=bind,source="$(pwd)/data",target=/data.How you’d spot it in prod: The quieter version of this bug doesn’t error — a typo’d source with no slash silently creates a surprise anonymous volume, and files you expected on the host “vanish.” A
docker volume lsfull of random-hash volumes is the tell.
Common error: Trying to delete a volume that a container still references —
docker volume rm pgdata— prints:Error response from daemon: remove pgdata: volume is in use - [3f9a1c7b2e8d4a6f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a]Why: Docker refuses to remove a volume while any container — even a stopped one — still uses it, and it prints the offending container’s ID so you can find it. This guard is deliberate: it stops you from deleting live data by accident.
Fix: Remove the container first, then the volume:
docker rm -f pg && docker volume rm pgdata. To sweep every volume no container references,docker volume prune— but read its warning, because that data is gone for good.How you’d spot it in prod: A cleanup script that fails on
volume is in useusually ran in the wrong order — it tried to reclaim storage before stopping the containers holding it. Tear down containers, then volumes.
Docker Volumes Interview Questions
Cover the answers below and say your own version out loud first — explain why the writable layer is ephemeral, and when you’d pick a named volume over a bind mount, before you reveal each answer. Recalling before revealing is what makes these stick when an interviewer asks them cold. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~30 more minutes today:
- 5 min — Run
docker system dfto see how much disk your images, containers and volumes each use, thendocker volume pruneto reclaim space from dangling volumes (read the confirmation warning before you typey). - 10 min — Reuse the bind mount with a web server:
docker run --rm -p 8080:80 -v ~/site:/usr/share/nginx/html:ro nginx(that-pis day 25’s port mapping), openlocalhost:8080, then edit~/site/index.htmlon the host and refresh — the container serves your live edits. - 15 min — Read the volumes and storage section of the Docker for DevOps guide for how volumes fit alongside images, networking and the compose file you meet tomorrow.
Why does data disappear when a container is removed? Both
A container's filesystem is a thin writable layer stacked on the read-only image layers. Anything the process writes — logs, uploaded files, database rows — lands in that top layer, and that layer is created with the container and destroyed with it. So docker rm throws the data away along with the container; stopping and restarting the same container keeps it, but removing the container loses it. That is by design: containers are meant to be disposable and identical, so you can kill one and start a fresh copy without a second thought. Anything you need to outlive the container has to be written to a volume or a bind mount, which lives outside the writable layer. The rule I use is: image is the app, volume is the data, never store state in the container itself.
What is the difference between a named volume and a bind mount? Both
Both give a container storage that survives it, but they differ in who owns the path. A named volume is managed by Docker — you give it a name, Docker stores it under its own directory, and you never care exactly where. It is the right default for data like a database, because it is portable across hosts and Docker handles permissions. A bind mount maps a specific host directory into the container, so the container sees your actual files at a path you choose. That is ideal in development — mount your source code so edits show up live — but it ties the container to that host's layout and can bring host permission quirks. My rule: named volumes for data the app owns, bind mounts for files I own and edit.
How would you persist a database running in a container? Product
I mount a named volume at the database's data directory. For Postgres that is /var/lib/postgresql/data, so docker run -v pgdata:/var/lib/postgresql/data postgres:17. The image writes all its state there, and because the volume outlives the container I can destroy and recreate the container — for an upgrade, say — and the data is still there when the new one mounts the same volume. Two things I am careful about: removing the container does not remove the volume, so cleanup has to be deliberate, and I never run two containers writing the same database volume at once. In production the data usually lives on managed storage or a real database service, but the volume pattern is exactly how you would run it locally or in a simple deployment.
-v versus --mount — which do you use and why? Both
They do the same job with different ergonomics. -v is the short, older syntax: -v pgdata:/var/lib/postgresql/data. It is compact, but it has a sharp edge — if the source has no leading slash Docker treats it as a named volume, so a mistyped bind path silently creates a surprise volume instead of erroring. --mount is the explicit key-value form: --mount type=volume,source=pgdata,target=/var/lib/postgresql/data, which is more verbose but self-documenting and fails loudly on a bad path. For quick interactive work I will use -v; in scripts and compose files, where clarity matters and a typo is expensive, I prefer --mount. Both end up in the same place — it is about how obvious the intent is.
Mark Day 26 complete
Tomorrow you stop juggling long docker run flags — docker compose describes a whole multi-container app in one file.
Stuck on today’s lab? Ask in Mission 90 Q&A