Volumes and Bind Mounts
Why this matters
Module 02 showed that a container's writable layer disappears when you
docker rm it — fine for stateless apps, fatal for a database or any
app that needs to keep data, or for a dev workflow where you want to
edit code on your host and see it reflected instantly in a running
container. Volumes and bind mounts are how you attach storage that
outlives (or lives outside) any single container.
Concepts
Both mount something from outside the image
A bind mount and a named volume do the same fundamental thing: they mount
storage from outside the container's image layers to a path inside
it, exactly like mount --bind on a Linux host makes one directory
appear at another path. They differ in what is mounted and who
manages it.
BIND MOUNT NAMED VOLUME
host path you manage Docker-managed storage
┌───────────────────┐ ┌───────────────────────┐
│ ~/project/app.py │ │ volume "appdata" │
└─────────┬─────────┘ │ (inside WSL2 VM) │
│ -v $(pwd)/app.py:... └───────────┬───────────┘
▼ │ -v appdata:/data
┌───────────────────┐ ┌────────────▼──────────┐
│ container /code/ │ │ container /data/ │
│ app.py (live) │ │ (Docker owns it) │
└───────────────────┘ └───────────────────────┘
edit on host = instant survives docker rm; not
inside container browsable from Explorer
Bind mount: your host path, your responsibility
A bind mount mounts an existing path from your host filesystem directly into the container. Docker doesn't manage this storage at all — it's just your files, visible at a different path inside the container. This is great for development: edit a file on the host and the container sees the change immediately, because there's no copy step involved.
Named volume: Docker's storage, Docker's responsibility
A named volume is a storage area Docker itself creates and manages.
On Docker Desktop's WSL2 backend it physically lives inside the Linux VM,
not somewhere you browse from Windows Explorer. Docker tracks it by name,
and it persists independently of any container's lifecycle — you can
docker rm every container that used it and the volume (and its data) is
still there. Best for data you want Docker to own and that you don't edit
from the host, like a database's data directory.
In Docker Desktop: the Volumes tab lists every named volume, its size, and which containers use it — the GUI equivalent of
docker volume ls. Click a volume to browse its contents and see the exact files inside, which is the easiest way to peek at managed storage you can't reach from your normal filesystem.
Why not just use the writable layer?
Because the writable layer is deleted with the container (docker rm),
and it makes multiple containers unable to share state. A mounted volume
is what lets a database container's data survive a docker rm + fresh
docker run, or lets two containers share a directory.
Where data can live Survives docker rm?
───────────────────── ───────────────────
writable layer NO (deleted with the container)
named volume YES (Docker-managed, independent)
bind mount YES (it's a host path, untouched)
Permissions carry over from Linux
Files in a bind mount keep their host UID/GID, and the process inside the
container (running as some UID, often root unless a USER was set)
needs matching permission to read/write them — the same ls -l /
chmod / chown reasoning as on bare Linux. The twist: "the user inside
the container" and "the user on your host" are usually different UIDs
unless you deliberately align them.
Anonymous volumes exist, but prefer named ones
An anonymous volume (a volume mount with no name) is Docker-managed
but hard to reference afterward — it shows up as a random hash. Useful in
some Dockerfile VOLUME declarations, but for anything you'll reuse,
prefer a named volume so you can find it later.
Command reference
| Command | What it does | Example |
|---|---|---|
docker volume create <name> |
Creates a named volume | docker volume create appdata |
docker volume ls |
Lists volumes | docker volume ls |
docker volume inspect <name> |
Shows a volume's mountpoint and metadata | docker volume inspect appdata |
docker volume rm <name> |
Deletes a volume (fails if in use) | docker volume rm appdata |
docker volume prune |
Deletes all volumes not used by any container | docker volume prune |
docker run -v <name>:<path> |
Mounts a named volume at <path> |
docker run -v appdata:/data ... |
docker run -v <hostpath>:<path> |
Bind-mounts a host path at <path> |
docker run -v "$(pwd)/app.py:/code/app.py" ... |
docker run --mount type=volume,src=<name>,dst=<path> |
Same as -v for volumes, explicit syntax |
docker run --mount type=volume,src=appdata,dst=/data ... |
docker run --mount type=bind,src=<hostpath>,dst=<path>,readonly |
Explicit bind mount, optionally read-only | docker run --mount type=bind,src="$(pwd)",dst=/code,readonly ... |
docker cp <container>:<path> <hostpath> |
Copies files between a container and the host without a mount | docker cp web:/tmp/note.txt ./note.txt |
Flag breakdown for the --mount forms:
--mount type=volume,src=appdata,dst=/data:type=volume— this is a Docker-managed named volume (not a host path).src=appdata— the volume's name.dst=/data— the path inside the container where it appears.
--mount type=bind,src="$(pwd)",dst=/code,readonly:type=bind— mount a host path directly.src="$(pwd)"— the host path (here, the current directory).dst=/code— where it appears in the container.readonly— the container can read but never write the mount.
The -v name:/path shorthand does the same as the volume --mount;
-v /host:/container does the same as the bind --mount. --mount is
more verbose but clearer and required for some options -v can't express.
Hands-on exercises
You'll create a tiny self-contained app in exercise 4; the earlier
exercises only need the alpine image. Nothing is downloaded.
(WSL2 Ubuntu terminal) Create and inspect a named volume:
docker volume create appdata docker volume inspect appdataExpect JSON including a
Mountpointpath inside the Docker Desktop VM — you can't browse it directly from/mnt/c/...; it's managed storage.(WSL2 Ubuntu terminal) Prove data in a named volume outlives a container. Write a file into it via a throwaway container, then read it from a different one:
docker run --rm -v appdata:/data alpine sh -c "echo 'persisted' > /data/note.txt" docker run --rm -v appdata:/data alpine cat /data/note.txtExpect
persistedto print — a completely different container instance read what the first one wrote, because the volume (not the container) owns the data.(Docker Desktop GUI, then CLI) Look at that volume in the GUI. Open the Volumes tab, click
appdata, and browse its contents — you'll seenote.txtwith the textpersisted. This is the GUI window into managed storage you otherwise can't reach. Cross-check from the CLI:docker run --rm -v appdata:/data alpine cat /data/note.txtExpect the same content the GUI showed — one volume, two views.
(WSL2 Ubuntu terminal) Set up a tiny app and bind-mount it for a live-edit dev loop. Create it inline:
mkdir -p ~/learn-docker/volume-lab && cd ~/learn-docker/volume-lab cat > app.py <<'EOF' from flask import Flask app = Flask(__name__) @app.get("/health") def health(): return {"status": "ok"} if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) EOF cat > requirements.txt <<'EOF' flask==3.0.3 EOF cat > Dockerfile <<'EOF' FROM python:3.12-slim WORKDIR /code COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 8000 CMD ["python", "app.py"] EOF docker build -t lab:v1 . docker run -d --name devmode -p 8010:8000 -v "$(pwd)/app.py:/code/app.py" lab:v1 curl -s http://localhost:8010/healthThe
-v "$(pwd)/app.py:/code/app.py"bind-mounts your host file over the one baked into the image. Now editapp.pyon the host — change"ok"to"ok-edited"in the/healthreturn — and, without rebuilding:docker restart devmode curl -s http://localhost:8010/healthExpect
{"status":"ok-edited"}— the bind mount made your host edit visible inside the container immediately; you only restarted the process, not rebuilt the image, because the code is live-mounted, not baked into a layer.(WSL2 Ubuntu terminal) Revert your edit to
app.pyback to"ok"so any later reuse behaves normally, then clean up:docker rm -f devmode.(WSL2 Ubuntu terminal) Compare
-vshorthand and--mountexplicit syntax doing the same thing:docker run --rm -v appdata:/data alpine ls /data docker run --rm --mount type=volume,src=appdata,dst=/data alpine ls /dataExpect identical output (
note.txtfrom exercise 2) from both.(WSL2 Ubuntu terminal) Read-only bind mounts. Mount the lab directory read-only and try to write from inside:
cd ~/learn-docker/volume-lab docker run --rm --mount type=bind,src="$(pwd)",dst=/code,readonly alpine sh -c "touch /code/newfile.txt"Expect a permission error (
Read-only file system) — the container cannot write back to a mount you markedreadonly, even though the underlying host directory is writable by you.Diagnose and fix: volume permission mismatch. Simulate a common failure — a non-root container user writing into a root-owned volume:
docker volume create permtest docker run --rm -v permtest:/data alpine chown 0:0 /data docker run --rm -v permtest:/data -u 1000:1000 alpine sh -c "touch /data/fail.txt"Flag note:
-u 1000:1000runs the container process as UID 1000, GID 1000 instead of root. Expect aPermission deniederror — UID 1000 can't write into a directory owned by UID 0 (root). Fix it by aligning ownership with the UID the container runs as:docker run --rm -v permtest:/data alpine chown 1000:1000 /data docker run --rm -v permtest:/data -u 1000:1000 alpine sh -c "touch /data/ok.txt && echo success"Expect
successto print — same reasoning as fixing a bare-Linux "permission denied" withchown.(WSL2 Ubuntu terminal) List and prune unused volumes:
docker volume ls docker volume rm permtest docker volume prune -f docker volume lsFlag note:
prune -fskips the confirmation prompt. Expectpermtestgone immediately, andpruneto remove any other volumes not referenced by an existing container. You can also delete a volume from the Volumes tab in Docker Desktop and confirmdocker volume lsagrees.(WSL2 Ubuntu terminal) Full cleanup for this module:
docker rm -f $(docker ps -aq) 2>/dev/null docker volume rm appdata 2>/dev/null docker volume lsExpect no leftover containers, and
appdata/permtestgone fromdocker volume ls.
Independent challenge
No commands given here — figure it out yourself using what you know from this module and earlier ones.
Task: Using only the alpine (or another small) image, demonstrate the full contrast between the three places data can live that this track has now covered: a container's writable layer, a named volume, and a bind mount. Write a distinct marker into each of the three, then remove and recreate the container and show which markers survive and which don't. Then reproduce and fix a permission failure: make a non-root container process (a specific UID) unable to write into a volume, then adjust ownership so it can. Explicitly connect the surviving-vs-lost outcome back to module 02's writable-layer lesson, and the permission fix back to ordinary Linux ownership reasoning.
Stuck? One hint
Mount a named volume and a host directory into the same throwaway container, write to /data in the volume and to the bind path and to a plain in-container path, then docker rm and re-run; for the permission part, run with a non-root -u and use a short-lived root container to chown the volume to that UID.
Common mistakes & troubleshooting
- Using a bind mount for a database's data directory. Works, but host-side permission/ownership quirks (especially crossing the WSL2/Windows boundary) cause more grief than a named volume, which Docker manages consistently. Prefer named volumes for anything Docker fully owns; prefer bind mounts for things you edit from the host.
- Forgetting that
-v hostpath:containerpathwill create an empty directory athostpathif it doesn't exist, silently masking a typo rather than erroring — always double-check the host path exists. - Expecting a rebuild to be necessary after a bind-mounted code change. For interpreted languages served by a restartable process (or one that auto-reloads), a mount means you often only restart the process, not rebuild the image — but this doesn't apply to compiled languages or anything baked in at build time.
- Permission-denied errors writing into a named volume from a non-root
container user. The volume's ownership doesn't automatically match
your container's UID — fix with
chownfrom a throwaway root container (exercise 8), or initialize ownership in your Dockerfile/entrypoint. - Trying to browse a named volume from Windows Explorer. It lives
inside the Docker Desktop WSL2 VM — use the Volumes tab in Docker
Desktop, a throwaway container (
docker run --rm -v name:/data alpine ls /data), ordocker cpto inspect it. - Confusing
docker volume prunewith a general cleanup. It only removes volumes with zero containers referencing them (including stopped ones).
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
Show questions
- What's the core difference between a bind mount and a named volume, in terms of who manages the storage?
- Why would you choose a bind mount for application source code during development, but a named volume for a database's data files?
- If you
docker rmevery container that references a named volume, is the volume's data gone? What if the data only ever lived in a container's writable layer? - Why did the permission-denied error happen in exercise 8, and how is
it conceptually the same problem you'd hit with
chmod/chownon plain Linux files? - Does
-v /host/path:/container/pathfail if/host/pathdoesn't exist yet, or does something else happen? - What does mounting with
readonly(or:roin the-vshorthand) change about a container's ability to modify the mounted path? - Where in Docker Desktop can you browse the actual files inside a named volume, and why is that useful?
Show answers
- A bind mount points at an existing host path that you (not Docker) manage directly; a named volume is storage that Docker itself creates, names, and manages, independent of any host directory you browse.
- Source code needs to be edited live from the host and seen instantly inside the container, which is exactly what a bind mount gives you with no copy step; a database's data directory doesn't need host editing and benefits from Docker managing ownership/location consistently, which a named volume provides.
- The named volume's data survives — volumes are independent of container lifecycle. Data that only existed in a container's writable layer is permanently gone once that container is removed.
- UID 1000 didn't have write permission on a directory owned by UID 0
(root) — identical to a bare-Linux "permission denied" writing into a
root-owned directory as a non-root user; the fix is the same
chown/permission adjustment either way. - Docker silently creates an empty directory at the host path if it doesn't already exist, rather than failing — which can mask a typo'd path as an apparently-working but empty mount.
- The container can still read the mounted path, but any write (create, modify, delete) fails with a read-only filesystem error, regardless of the underlying host files' permissions.
- The Volumes tab — click a volume to browse its files. It's useful because managed volumes live inside the Docker Desktop VM and aren't reachable from your normal Windows/WSL2 filesystem.
Further reading & sources
- Docker: Manage data in Docker (storage overview) - the top-level guide comparing volumes, bind mounts, and tmpfs.
- Docker: Volumes - the reference for creating, inspecting, and managing named volumes.
- Docker: Bind mounts - details on mounting host paths, including read-only and the
--mountsyntax. - Docker: docker volume CLI reference - full reference for
create,ls,inspect,rm, andprune. - Docker: Troubleshoot volume and permission issues - background for the UID/GID ownership problem exercise 8 reproduces.
Next
Continue to 05-docker-networking to connect containers to each other and to the outside world.