Docker Compose volumes: the silent data-loss trap I keep seeing
Anonymous volumes in Docker Compose look identical to named volumes until you run docker-compose down and your data vanishes. Here's what to watch for.
I've watched junior devs — and honestly a few senior ones — lose days of local database state because of a single missing string in a docker-compose.yml. Not a typo. Not a wrong password. A volume declaration that looks correct but behaves completely differently under docker-compose down. This one is subtle enough that I want to write it down.
What the two volume types actually mean
Docker has three ways to persist data from a container: bind mounts (a path on the host), named volumes (Docker-managed, identified by a string name), and anonymous volumes (Docker-managed, identified by a hash). The confusion lives entirely in the third category, and it's easy to create one by accident.
A named volume is what you want almost everywhere:
services:
db:
image: postgres:16
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Docker creates a volume called <project>_postgres_data. It survives docker-compose down. It survives docker-compose stop. It goes away only when you explicitly run docker-compose down -v or docker volume rm.
An anonymous volume is what you get when you forget the top-level volumes: block, or when the image itself declares a VOLUME in its Dockerfile and you don't map it to anything:
services:
db:
image: postgres:16
volumes:
- /var/lib/postgresql/data # <-- anonymous. no name on the left side.
Docker creates a volume with a hash for a name — something like a3f9b2c1d4e5.... It survives docker-compose stop. It does not survive docker-compose down. That command, by default, removes anonymous volumes attached to containers it tears down. Named volumes are left alone. Anonymous volumes are deleted.
The trap: both behave identically during normal development. You up, you stop, you up again — data is there. Nobody notices anything wrong until they run down to reset their environment or switch branches, and suddenly the database is empty.
The sneaky third case: implicit anonymous volumes from the image
This one bites people who think they did everything right. The official Postgres image declares VOLUME /var/lib/postgresql/data in its Dockerfile. When Docker sees that declaration and you haven't mapped that path to a named volume, it creates an anonymous volume automatically — even if you have zero volumes: entries in your Compose file.
So this:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
...silently creates an anonymous volume for /var/lib/postgresql/data. Your data persists across stop/start. It vanishes on down. Most people don't discover this until they're demoing something and blow away the stack to restart clean.
You can see this happening with docker inspect:
docker inspect <container_id> | jq '.[0].Mounts'
If you see "Type": "volume" with a "Name" that's a 64-character hex string, you've got an anonymous volume.
A production-like dev setup that gets this right
Here's the pattern I use for projects at NWOS. Laravel app, Postgres, Redis. Everything named explicitly.
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/var/www/html
depends_on:
- db
- redis
db:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: secret
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
ports:
- "6379:6379"
volumes:
postgres_data:
redis_data:
The important details:
- Both services map their data directory to a named volume.
- Both named volumes are declared at the top level under
volumes:. Skipping that declaration is one way to accidentally go anonymous. - Redis also declares
VOLUME /datain its image, so same rules apply.
With this setup, docker-compose down leaves postgres_data and redis_data intact. docker-compose down -v nukes them. That's the intended behavior — -v is opt-in destruction.
Checking what you already have
If you're inheriting a project and want to audit it:
# List all volumes, named and anonymous
docker volume ls
# Named volumes have readable names like "myproject_postgres_data"
# Anonymous volumes look like "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0"
# Find dangling (anonymous, unreferenced) volumes
docker volume ls -f dangling=true
If your Compose project's database volume shows up as a 64-char hash, fix it before someone runs down and wonders where the data went.
To migrate an existing anonymous volume to a named one without losing data, you can do it with a temporary container:
# 1. Create the named volume
docker volume create myproject_postgres_data
# 2. Copy from the anonymous volume to the named one
docker run --rm \
-v <anonymous_volume_hash>:/from \
-v myproject_postgres_data:/to \
alpine sh -c "cp -av /from/. /to/"
# 3. Update your compose file, bring it back up
Not glamorous, but it works.
The gotchas that bit me specifically
Gotcha 1: down behavior changed between Compose versions.
Older versions of docker-compose (v1, the Python one) didn't always clean up anonymous volumes on down by default. Compose v2 (the Go plugin, docker compose with a space) is more aggressive about it. If you're on a team where some people use v1 and some use v2, you'll get inconsistent behavior on the same docker-compose.yml. I had this exact situation on a healthcare project — one dev's environment kept its database, another's didn't, and we spent half a day figuring out why.
Gotcha 2: The volumes_from pattern.
If you're using volumes_from to share volumes between containers (older pattern, mostly replaced by named volumes), the lifecycle gets even murkier. Don't use it for persistent data. It was designed for a different era of Docker usage.
Gotcha 3: Named volumes don't reflect Compose file changes automatically.
If you already have a postgres_data volume from a previous config, changing the Postgres version or environment variables in your Compose file won't reinitialize it. The volume holds the old data directory, and Postgres 16 won't touch a Postgres 15 data dir without a migration. People expect docker-compose up --build to reset everything. It doesn't. This is correct behavior, but it surprises people.
Gotcha 4: .dockerignore and bind mounts are a separate problem.
I've seen people conflate the volume persistence issue with bind mount confusion. They're different. Bind mounts (./local/path:/container/path) are always persistent because they're just your filesystem. The lifecycle issue only applies to Docker-managed volumes (named and anonymous). Don't mix these up when debugging.
When I'd reach for each
Named volumes: Always, for anything stateful in a dev or staging environment. Databases, file uploads, cache persistence, ML model caches. Named volumes are the right default. The extra five lines in your Compose file cost nothing.
Anonymous volumes: Almost never intentionally. The only legitimate use case I can think of is when you explicitly want data to be ephemeral and scoped to a single container lifecycle — like a scratch space for a build step. Even then, I'd probably use a tmpfs mount instead, which is more explicit about the intent.
Bind mounts: For source code in dev (so you get live reloading without rebuilding), for config files you want to edit outside the container, for shared directories where you need the host to own the files. Not for database data — permissions and performance get weird, especially on macOS where Docker Desktop's filesystem virtualization adds latency.
For production, you're not using Compose anyway (or you shouldn't be). You're on Kubernetes with PersistentVolumeClaims, or you're using managed databases (RDS, Cloud SQL, whatever). The named volume pattern in Compose is about keeping dev environments honest, not about production storage architecture.
The rule I follow
Every stateful service in a docker-compose.yml gets an explicit named volume. Every named volume gets declared in the top-level volumes: block. I make this a PR checklist item. It's a two-minute fix that has saved several hours of "why is my database empty" conversations.
Anonymous volumes aren't evil — they're just a footgun in disguise. They look right, they work right, and then they eat your data the first time someone runs docker-compose down to reset their environment. Name your volumes. It's not a style preference.
Need help shipping something like this? Get in touch.