Docker – Volumes, Bind Mounts and Keeping Your Data

July 15, 20264 min readUpdated 8/21/2026

Lesson 3: a container gets a thin writable layer on top of the image, and that layer belongs to the container. Remove the container and everything written to it is gone.

Which is exactly what you want for a stateless API and exactly what you do not want for a database. So a database in a container needs somewhere else to write.

Named volumes

services:
  mysql:
    volumes:
      - mysql-data:/var/lib/mysql

volumes:
  mysql-data:

Two parts, both required. The volumes: at the bottom declares it; the line inside the service mounts it. Left of the colon is the volume name, right is the path inside the container.

Docker creates and owns the storage:

$ docker volume ls --filter name=pizza_
pizza_api-uploads
pizza_mysql-data

$ docker volume inspect pizza_mysql-data --format '{{.Mountpoint}}'
/var/lib/docker/volumes/pizza_mysql-data/_data

The pizza_ prefix is the Compose project name. That is what keeps this stack's database separate from another project's, and it is why name: pizza at the top of the compose file matters — without it the project name comes from the directory, and cloning the repo into a differently-named folder silently gives you a second, empty database.

⚠️ On a Mac that mountpoint is inside Docker Desktop's Linux VM, not on your filesystem. Do not go looking for it in Finder.

Proving it works

Worth doing once so you trust it:

$ docker compose exec mysql mysql -uroot -e \
    "CREATE TABLE pizza.volume_probe (note VARCHAR(64)); \
     INSERT INTO pizza.volume_probe VALUES ('survived');"

$ docker compose down
 Container pizza-mysql-1  Removed
 Network pizza_default    Removed

$ docker compose up -d
$ docker compose exec mysql mysql -uroot -e "SELECT note FROM pizza.volume_probe;"
note
survived

The container was destroyed and recreated. The data did not notice.

down versus down -v

docker compose down        # containers and network go. Volumes stay.
docker compose down -v     # ... and the volumes are deleted.

-v is not undoable and there is no confirmation prompt. It is the right command when a migration has left the schema in a state you would rather start over from, and the wrong one every other time. Get in the habit of typing down and adding -v deliberately, rather than the reverse.

Bind mounts

The other kind. Instead of storage Docker manages, you mount a path from your own machine:

volumes:
  - ./hasura/metadata:/hasura-metadata

The giveaway is the leading ./ or / — a path rather than a name.

Bind mounts are for when you need to see the files: source you are editing while the container runs it, a config file you want to tweak without a rebuild, output the container produces that you want to keep.

Three things to know about them.

The host wins. Whatever is at that host path replaces what the image had at that container path. Bind-mount an empty directory over /app and the application is gone.

Permissions are the host's. A container running as uid 999 writing to a bind-mounted directory owned by your user gets Permission denied — a specifically Linux problem, since Docker Desktop papers over it on macOS and Windows.

They are slower on macOS and Windows. Every read and write crosses the VM boundary. Tolerable for source files; genuinely painful for a database's write pattern, which is one of the reasons the compose file in this track uses a named volume for MySQL.

Which to use

database storage           named volume
uploaded files             named volume
source code, live reload   bind mount
a config file to tweak     bind mount
build output to keep       bind mount

The rule underneath: if only the container needs it, use a named volume; if you need it too, bind mount it.

Uploads, and a real trap

volumes:
  - api-uploads:/app/uploads

The API writes product images to ./uploads/products inside the container. Without this line every redeploy loses them, and a redeploy is not a rare event.

The trap: the image creates that directory owned by the app's user —

RUN mkdir -p /app/uploads/products && chown -R pizza:pizza /app/uploads

— and a named volume mounted over a non-empty directory copies the image's contents, ownership included, on first use. That is why this works without any further chown. A bind mount does no such copy; it presents the host directory as-is, and the container's user very likely cannot write to it. Same two lines of YAML, completely different outcome.

Backups

Compose does not back anything up. A volume is a directory in the VM, and the standard trick is a throwaway container that can see both it and your machine:

docker run --rm \
  -v pizza_mysql-data:/from \
  -v "$PWD":/to \
  alpine:3.21 tar czf /to/mysql-backup.tar.gz -C /from .

For a database, prefer its own dump tool — mysqldump, pg_dump — over a file-level copy of a running data directory, which can catch it mid-write.

Cleaning up

docker volume ls
docker volume ls --filter dangling=true   # attached to nothing
docker volume prune                       # delete those
docker system df -v                       # what is actually using the space

docker volume prune deletes volumes no container currently references — which includes the database of a stack you took down last week and meant to bring back. ls first.

Next: configuring the same image differently in different places.