Skip to content

06 - Volumes and Bind Mounts

What this session is

About 45 minutes. You'll learn how containers persist data - through bind mounts (mount a host path into the container) and named volumes (Docker-managed storage).

The problem

By default, a container's filesystem disappears when the container is removed. For databases, uploaded files, user data - anything you need to keep - that's a problem.

There are three ways to address it:

Mechanism What it is Use case
Named volume Docker-managed storage living in /var/lib/docker/volumes/ Production data (databases, persistent app state).
Bind mount Mount a host directory into the container Development (live-reload source code).
tmpfs In-memory filesystem Secrets, temporary fast scratch.

Of these, the first two are the default tools.

Bind mount: mount a host directory

docker run --rm -v $(pwd):/app -w /app python:3.12-slim python -c "import os; print(os.listdir('.'))"

What's new: - -v HOST_PATH:CONTAINER_PATH - mount the host directory into the container. - -w /app - set the container's working directory. - The container sees your current directory as /app.

If the container modifies files at /app, they appear on your host. If the host modifies them, the container sees the new state. Two-way mirror.

The modern syntax (more readable):

docker run --rm --mount type=bind,source=$(pwd),target=/app python:3.12-slim

Use whichever you find clearer; -v is shorter and very common.

A real use case: live development

You have a Python app you're actively editing. Mount the source so the container always sees the latest version:

docker run -d --rm --name dev -p 8000:8000 \
  -v $(pwd):/app -w /app \
  python:3.12-slim python app.py

Edit app.py on your host. The container sees the new version immediately. Restart the container (or use a hot-reloader like uvicorn/flask --reload) to pick up changes.

This is the standard "containerized dev environment" pattern. No installing Python locally; no version conflicts.

Read-only mounts

docker run --rm -v $(pwd):/app:ro python:3.12-slim ls -la /app

The :ro suffix makes the mount read-only. The container can't modify /app. Useful for "give the container access but don't let it mess with my files."

Named volumes

A named volume is Docker-managed storage. You name it; Docker stores it.

docker volume create mydata
docker volume ls
docker volume inspect mydata

Use it:

docker run -d --rm --name pg \
  -e POSTGRES_PASSWORD=secret \
  -v mydata:/var/lib/postgresql/data \
  postgres:16

Postgres writes to /var/lib/postgresql/data; that's actually the volume mydata. Stop and remove the container; the data is still in mydata. Recreate the container with the same volume - your database is intact.

Inspect:

docker volume inspect mydata
# Shows: Mountpoint: /var/lib/docker/volumes/mydata/_data

You can see where Docker stored it on the host. On macOS/Windows it's inside Docker's VM.

Cleanup:

docker volume rm mydata               # remove
docker volume prune                   # remove all unused volumes

Use volumes for things you want to keep across container restarts. Bind mounts for development.

Bind vs volume: which when?

Use case Pick
Live-editing source code during development Bind mount
Database data (Postgres, Redis, etc.) Named volume
Sharing files between containers Named volume
Loading a single config file at runtime Bind mount (read-only)
App state that must survive container removal Named volume
Backing up data Easier with named volume

tmpfs: in-memory

For sensitive data that should never hit disk (one-time secrets, temp files you don't want persisted):

docker run --rm --tmpfs /tmp:size=64M alpine sh -c "echo data > /tmp/x && ls -la /tmp"

/tmp is now an in-memory filesystem. Survives nothing - vanishes when the container exits.

Mounting a single file

docker run --rm -v $(pwd)/myconfig.yaml:/etc/app/config.yaml:ro myapp

Mounts just one file (not a directory). Common for injecting config.

Backups

Backup a named volume by running a temporary container that tars it up:

docker run --rm \
  -v mydata:/source:ro \
  -v $(pwd):/backup \
  alpine tar -czf /backup/mydata-$(date +%Y%m%d).tar.gz -C /source .

Reads the volume mounted read-only at /source; writes a tarball to your current directory via the bind mount at /backup.

Restore:

docker run --rm \
  -v mydata:/target \
  -v $(pwd):/backup \
  alpine tar -xzf /backup/mydata-20260517.tar.gz -C /target

(Backup strategy: this is the manual-and-simple form. Production uses dedicated backup tools.)

Common gotchas

  • Permissions / ownership. If your host user is UID 1000 and the container's app user is also UID 1000, file ownership lines up. If they differ, you get "permission denied" inside the container or strange ownership on host files. Match UIDs with --user, or chown in the Dockerfile.

  • Hidden files in the container. If you bind-mount a host directory into a container path that already has files, the container's files become hidden (the mount overlays). On the host, you see your files. In the container, you see your files (not the originals). Removing the mount restores the originals.

  • $(pwd) only works in POSIX shells. On Windows PowerShell, use ${PWD}. In CMD, use %cd%.

Exercise

  1. Bind-mount development: create app.py with a small script. Run:

    docker run --rm -v $(pwd):/app -w /app python:3.12-slim python app.py
    
    Edit app.py on host; re-run. The container sees the change.

  2. Named volume for Postgres:

    docker run -d --name pg \
      -e POSTGRES_PASSWORD=secret \
      -v pgdata:/var/lib/postgresql/data \
      postgres:16
    docker exec -it pg psql -U postgres -c "CREATE TABLE notes (text TEXT);"
    docker exec -it pg psql -U postgres -c "INSERT INTO notes VALUES ('hello');"
    docker stop pg && docker rm pg
    # Recreate with same volume:
    docker run -d --name pg -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:16
    docker exec -it pg psql -U postgres -c "SELECT * FROM notes;"
    # 'hello' is still there.
    docker stop pg && docker rm pg
    docker volume rm pgdata
    

  3. Read-only config:

    echo "name: alice" > config.yaml
    docker run --rm -v $(pwd)/config.yaml:/etc/app/config.yaml:ro alpine cat /etc/app/config.yaml
    

What you might wonder

"What's the actual difference between -v and --mount?" -v is older, terser, less explicit. --mount is newer, key=value syntax, more explicit. Both work. Read both forms in real code.

"Where does my named volume live physically?" On Linux: /var/lib/docker/volumes/<name>/_data/. On macOS/Windows: inside Docker Desktop's VM (you don't see them directly).

"Can two containers share a volume?" Yes - just mount the same named volume in both. Useful for "writer container produces data, reader container consumes."

"Bind mount or named volume on macOS / Windows?" Bind mounts on macOS/Windows are slower than on Linux (the file system has to translate across the VM boundary). Performance-sensitive workloads (Postgres, Rails dev, etc.) should prefer named volumes when possible.

Done

  • Mount host directories into containers (bind mounts).
  • Use named volumes for persistent data.
  • Use tmpfs for in-memory storage.
  • Mount single files for config injection.
  • Back up and restore named volumes.
  • Pick the right mechanism for each use case.

Next: Networks and ports →

Comments