DevTools Hub

Search tools

Search for a developer tool

Docker Compose Volumes Explained

Part of the Docker Toolkit

"Does my data survive a restart" is the question people usually ask about volumes, and the honest answer is "depends which of three different things you used, and which exact command you ran" — not a satisfying answer, but a precise one. There's a second, more surprising question people don't think to ask at all: a volume mount doesn't just add persistence, it can silently replace what a container sees at that path compared to what's actually baked into the image.

Three different things, one word

  • Bind mount./src:/app. Maps a specific host path directly into the container. Docker doesn't manage or own it at all; it's literally the host directory, visible and editable from both sides in real time. The standard choice for local development, where live code changes need to show up inside the container immediately.
  • Named volumedbdata:/var/lib/postgresql/data, with dbdata declared under a top-level volumes: key. Docker creates and manages the actual storage location itself (opaque to you — you don't need to know or care where on the host it physically lives), tracked by name, and it persists across container recreation independent of anything on the host filesystem.
  • Anonymous volume — a container path with no named source at all. Docker still creates a real, managed volume for it, just with an auto-generated hash instead of a name — which matters because it means the same lifecycle rules as a named volume apply, including that it isn't cleaned up automatically.

A volume mount can hide what the image actually built

This is the mechanic behind the single most common real-world Compose confusion: a Dockerfile runs npm install during the build, producing a real node_modules directory baked into the image at /app/node_modules. A compose file then bind-mounts the project directory over the same path for live reload:./:/app. The bind mount doesn't merge with what was already there — it shows exactly the host directory's real contents, nothing more. If node_modules was never installed on the host, it's simply not part of what the bind mount has to show, and the container's view of /app loses the dependencies the image spent an entire build step installing.

A named volume behaves differently on this specific point: the first time an empty named volume is mounted over a path, Docker copies whatever the image already had at that path into the volume before handing control to the container. A bind mount never does this — it has no relationship to the image's filesystem at all, only to the host's.

Image (built)
/app/index.js
/app/node_modules/installed
Bind mount only
./:/app
/app/index.jsfrom host
/app/node_modules/hidden
Bind mount + anonymous volume
./:/app, /app/node_modules
/app/index.jsfrom host
/app/node_modules/preserved

The standard fix: layer a second, anonymous volume on just the node_modules subpath so it 'wins' over the broader bind mount for that one directory, keeping the image's installed dependencies visible.

services:
  web:
    build: .
    volumes:
      - ./:/app          # bind mount — live code from the host
      - /app/node_modules # anonymous volume — pokes a hole back through the bind mount above

Compose applies both mounts, and the more specific path (/app/node_modules) takes precedence over the broader one (/app) for anything under it — the anonymous volume keeps serving the image's installed node_modules instead of whatever the bind mount would otherwise show there.

Does docker compose down delete it?

downdown -v
Bind mountUntouched — it's a host path, Docker never owned itUntouched — same reason
Named volumeKeptRemoved
Anonymous volumeKept — and orphaned, since nothing still references itRemoved

The anonymous-volume row is the real trap: without -v, every docker compose up / down cycle that recreates a container with an anonymous volume leaves the previous one behind, disconnected from anything — a slow, silent accumulation of disk usage that docker volume prune is the actual cleanup for, not something that happens on its own.

Sharing a volume across projects: external: true

Marking a volume external: true tells Compose it already exists — created outside this project, by another compose stack or manually via docker volume create — and Compose should reference it rather than try to create or manage its lifecycle. This is the mechanism for two separate compose projects that need to genuinely share the same data.

Read-only mounts

Appending :ro to either a bind mount or a named volume (./config:/app/config:ro) mounts it read-only inside the container — a simple, real safeguard for anything the container should be able to read but never legitimately needs to modify, like a shared config file.

Try it yourself

Docker Compose Validator catches malformed volumes: entries and references to a named volume that was never declared under the top-level volumes: key, and Docker Compose Visualizer shows exactly which volumes attach to which services at a glance, instead of tracing indentation by eye across a long file.

FAQ

Why is node_modules empty or missing after I bind-mounted my project directory?

The bind mount is showing the host directory's actual contents, and node_modules was never installed there — only inside the image, during the build. Add a second, anonymous volume scoped just to /app/node_modules so that specific subpath keeps serving the image's installed copy instead of the bind mount's empty view of it.

Does docker compose down delete my database data?

Not by default — a named volume survives a plain down. It's only removed with the explicit -v flag, which is exactly why that flag exists separately rather than being the default behavior.

What's the difference between a named volume and a bind mount, in one sentence?

A named volume is storage Docker creates and manages itself, tracked by name; a bind mount is just an existing host directory Docker points a container at, without owning or managing it at all.

Related tools