Most people learn early on that docker compose down followed by up keeps the database. Fewer can say why, and that gap is where data gets lost: someone copies down -v from a tutorial, or assumes :ro protects a volume from everything.

Here's a short quiz. Which of these are true?

A. A volume declared under top-level volumes: but not mounted by any service makes docker compose up fail.

B. Adding :ro to a service's volume mount makes it read-only inside that container.

C. With external: true, Compose looks for an existing volume instead of creating one.

D. A named volume that a service references and that is declared at the top level gets created automatically, using the default driver.

B, C and D are true. A is false. The rest of this post goes through why.

Declaring is not mounting

services: app: image: alpine:3.21 command: sleep 3600 volumes: app_data: 

This is valid. app_data is defined, but nothing uses it, and up doesn't complain. To actually get it into a container you need a service-level mount:

services: app: image: alpine:3.21 command: sleep 3600 volumes: - app_data:/data volumes: app_data: 

The top-level block defines the volume (and its driver, name, options). The service-level entry attaches it to a path in one container. They're separate steps, and a typo in either one gives you a very different result than you expected.

:ro belongs to the mount

volumes: - app_data:/data:ro 

That's source, destination, and mode. The default mode is rw. The long syntax says the same thing:

volumes: - type: volume source: app_data target: /data read_only: true 

What :ro blocks is writes through that one mount. Another container can still mount the same volume read-write, and anyone with access to the Docker host can still change the data underneath. A reporting container that reads exports it shouldn't touch is a good use. A data-protection mechanism it is not.

It's also easy to confuse with the service-level read_only: true, which makes the container's root filesystem read-only. Different setting, different problem.

external: true hands over lifecycle

Say an admin created the volume beforehand:

docker volume create shared_app_data 

and the Compose file points at it:

volumes: app_data: external: true name: shared_app_data 

Compose now expects shared_app_data to exist. If it doesn't, up fails; it won't quietly create an empty one. Compose also won't remove it. Without name:, it looks for a volume called app_data exactly as written, with no project prefix.

"External" here has nothing to do with network storage. The volume can live on the same host. The word is about who owns creation and deletion.

Normal named volumes just get created

For an ordinary Compose-managed volume you don't run docker volume create. docker compose up -d makes it if it's missing, using the local driver by default. The name is prefixed with the project name, so key app_data in a project called demo becomes demo_app_data:

docker volume ls docker volume inspect demo_app_data 

down vs down -v

Command Containers Managed named volumes External volumes
stop stopped kept kept
down removed kept kept
down -v removed removed kept
up -d --build recreated as needed reused reused

down -v removes named volumes declared in the Compose file plus anonymous volumes attached to the containers. It won't touch bind-mounted host directories, and it can fail on a volume that's still in use.

Anonymous volumes have a quirk. A plain down leaves them on disk, but since they have no stable name in the Compose file, the next up creates new ones instead of reattaching the old. They're kept, but not reused, and they pile up.

So when someone asks why the database is still there after a rebuild: rebuilding an image doesn't touch volumes. And when someone's data vanished after down -v: that flag asked for exactly that.

A lab you can run

name: volume-lab services: writer: image: alpine:3.21 command: sh -c 'date >> /data/events.log && sleep 3600' volumes: - app_data:/data reader: image: alpine:3.21 command: sleep 3600 volumes: - app_data:/data:ro volumes: app_data: 
docker compose up -d # give the writer a second to create the file, then: docker compose exec reader cat /data/events.log docker compose exec reader sh -c 'echo test >> /data/events.log' 

The last command should fail with a read-only filesystem error. Now recreate the containers:

docker compose down docker compose up -d docker compose exec reader cat /data/events.log 

The log now has two timestamps, because the volume outlived the containers. Check it with docker volume inspect volume-lab_app_data.

Only run docker compose down -v here if you want to wipe the lab. Don't paste it into a production directory without checking which volumes it will hit.

Before you deploy something stateful

Check four things:

  1. Is the volume declared where Compose expects it?
  2. Which services mount it, and at which paths?
  3. Does each service really need write access?
  4. Does Compose own this volume, or is it external?

And remember that a volume isn't a backup. It survives container replacement, but not corruption, a mistaken docker volume rm, or a dead disk. Keep backups, and test restoring them.