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/_dataThe 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
survivedThe 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-metadataThe 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 mountThe 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/uploadsThe 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 spacedocker 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.