Docker Compose volumes: bind vs named
Bind mounts and named volumes in Docker Compose: which to use for config and for data, the file permission traps, and how to inspect, back up and migrate.
Bind mount or named volume: the short answer
Docker Compose volumes come in two kinds, and the choice is about who owns the files. Use a bind mount for files you write and read yourself, so config, templates, static sites. Use a named volume for data the application owns, so database files, search indexes, uploaded media. A bind mount points at a path on the host that you can open in an editor. A named volume is storage Docker creates and tracks for you, and you reach it through Docker.
Both appear under the same volumes: key inside a service, which is why they get mixed up. The difference is the left side of the colon. A left side starting with . or / is a host path, so it is a bind mount. Anything else is a name, so it is a named volume, and that name must also be declared in the top-level volumes: block.
The two syntaxes in a compose file
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: changeme
volumes:
- pgdata:/var/lib/postgresql/data
web:
image: nginx:1.27
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./site:/usr/share/nginx/html:ro
volumes:
pgdata:pgdata:/var/lib/postgresql/data is a named volume. ./nginx.conf:/etc/nginx/nginx.conf is a bind mount, and :ro mounts it read only, which is the right default for config a container should never rewrite. Forget the top-level volumes: entry and Compose stops with service "db" refers to undefined volume pgdata.
Bring it up and list what Docker created:
docker compose up -d
docker volume lsThe volume is not called pgdata. It is called <project>_pgdata, where the project name defaults to the name of the directory holding the compose file. A directory called myapp gives myapp_pgdata. This matters because renaming the directory gives you a new empty volume, and the application looks like it lost its data. It did not: the old volume is still listed by docker volume ls. Pin the name with name: in the compose file, or set COMPOSE_PROJECT_NAME, if the directory might move. Settings like that belong with your other Compose environment files and secrets.
Why permission errors only bite bind mounts
This is the biggest practical difference, and it comes from one rule: a named volume that is empty at first use is seeded from the image, and a bind mount never is.
When Docker mounts an empty named volume over a directory that already has content in the image, it copies that content into the volume, with the ownership and modes the image set. The official Postgres image ships /var/lib/postgresql/data owned by its own postgres user, so the volume comes out owned by that same numeric id, and the database starts.
A bind mount does the opposite. Whatever is on the host is what the container sees, ownership included, and the image content at that path is hidden. If the host directory does not exist, the Docker daemon creates it, and the daemon runs as root, so you get a directory owned by root:root. A container process running as a non-root user then cannot write to it:
PermissionError: [Errno 13] Permission denied: '/data/app.db'The fix is to make the numbers agree. Ownership across a bind mount is compared by numeric user id, not by name, because the container has its own /etc/passwd. A user called app inside the container means nothing on the host. Uid 1000 means uid 1000 on both sides.
id -u
mkdir -p ./data
docker compose exec web id
sudo chown -R 1000:1000 ./datadocker compose exec web id prints the uid the container process actually runs as. Match the host directory to that number, or pin the container to your number with user: "1000:1000" in the service. Pinning user: is cleaner for an application you wrote yourself. Chowning the host directory is safer for an image you did not write, because some images start an entrypoint as root, drop privileges, and expect specific ownership underneath.
Two more traps are worth knowing. On Fedora, RHEL and other systems with SELinux (security-enhanced Linux) enforcing, a bind mount is denied until it is relabelled, so add :z for a path shared between containers or :Z for a path only one container should use, written as - ./data:/data:Z. And a bind mount of a single file, rather than a directory, breaks when an editor replaces the file instead of writing in place, because the mount follows the original inode. The container keeps seeing the old content until you restart it. Mount the parent directory when the file is edited often.
Performance: where the gap is real
On a Linux server both kinds go through the same kernel path, so the throughput difference is small enough that you should not choose on that basis. Named volumes using the default local driver live on the same filesystem as the rest of Docker, under /var/lib/docker/volumes/, and a bind mount lives wherever you pointed it.
The gap appears on Docker Desktop for macOS and Windows, where containers run inside a virtual machine. A bind mount there crosses from the host filesystem into that virtual machine through a file sharing layer, and workloads with many small file operations, such as a Node.js dependency tree or a PHP framework cache, slow down noticeably. Named volumes stay inside the virtual machine and do not pay that cost. This is why so many development compose files bind-mount the source directory but declare a named volume over node_modules.
The other real difference is where the bytes land. A bind mount to /mnt/backup puts data on that disk. A named volume lands on whatever filesystem holds /var/lib/docker, which on a VPS is usually the root disk. A database growing inside a named volume fills the same disk your system logs are on. Check it before it becomes an incident:
docker system df -v
df -h /var/lib/dockerdocker system df -v lists every volume with its size, and marks the ones no container refers to any more.
Inspecting a named volume
A named volume is not a black box. Ask Docker where it is:
docker volume inspect myapp_pgdataThe Mountpoint field gives a real host path, normally /var/lib/docker/volumes/myapp_pgdata/_data. You can read it with sudo ls, and that is useful for a quick check. Do not treat it as a place to edit files. Writing there as root recreates the ownership problem described above, and the path is a detail of the local driver that other volume drivers do not share.
The safe way to look inside is a throwaway container that mounts the volume:
docker run --rm -v myapp_pgdata:/vol alpine ls -la /volThat works for any driver, sees the same permissions the real container sees, and leaves nothing behind because of --rm.
Backing up each kind
A bind mount is an ordinary directory, so any file-level backup tool already handles it. Point the backup at the host path and you are done. A named volume needs one extra step, because the tool has to get inside it. Mount the volume and a host directory into the same short-lived container, then write an archive:
docker run --rm \
-v myapp_pgdata:/data:ro \
-v "$PWD":/backup \
alpine tar czf /backup/pgdata.tar.gz -C /data .Restore by reversing it into a fresh volume:
docker volume create myapp_pgdata_restored
docker run --rm \
-v myapp_pgdata_restored:/data \
-v "$PWD":/backup \
alpine tar xzf /backup/pgdata.tar.gz -C /datatar preserves numeric ownership when it runs as root inside the container, which is what keeps the restored volume usable by the application.
One warning applies to both kinds. Copying a database's files while the database is running gives you an archive of a moving target, and it can restore into a corrupt state. Stop the service first, or dump through the database's own tool, as in docker compose exec -T db pg_dump -U postgres appdb > appdb.sql. That produces a plain file you can then include in a normal encrypted restic backup routine alongside your compose files.
Migrating a bind mount to a named volume
The move is a copy, not a rename, and it takes about a minute.
docker compose down
docker volume create myapp_pgdata
docker run --rm \
-v "$PWD/data":/from \
-v myapp_pgdata:/to \
alpine sh -c 'cp -a /from/. /to/'cp -a keeps ownership, modes and timestamps, so the container user that could read the old directory can still read the new volume. Then change the service to use pgdata:/var/lib/postgresql/data, add pgdata to the top-level volumes: block, run docker compose up -d, and read the application's logs before you delete the old directory. Going the other way uses the same command with /from and /to swapped.
Keep one thing in mind while you test. docker compose down leaves named volumes alone, but docker compose down -v deletes every named volume the project declares, and there is no undo. A bind mount survives both, because Docker never owned that directory. If the lifecycle commands are still new to you, the Docker Compose basics guide for a VPS walks through them.
Choosing, service by service
Ask who writes the file. Config you edit in a text editor and commit to git belongs in a bind mount, mounted :ro, because you want it visible and versioned. Application state you never open by hand belongs in a named volume, because Docker seeds the permissions correctly and the data does not depend on a host path.
The mixed case is media. A photo library is written by the application but also managed by you, and it is often large enough to want a specific disk. Bind mount it to a path on that disk, and set the ownership deliberately once. That is the pattern most self-hosted stacks settle on: named volumes for databases and caches, bind mounts for config and for the large directory you care about.
FAQ
What is the difference between a bind mount and a named volume?
A bind mount maps a path on the host into the container, so both sides see the same directory and you can edit it with normal tools. A named volume is storage Docker creates and manages, referred to by name and declared in the top-level volumes: block. The practical split is ownership: bind mounts for config you maintain, named volumes for data the application maintains.
Why do I get "permission denied" with a bind mount but not a named volume?
An empty named volume is seeded from the image, so it inherits the ownership the image set and the container user can write to it. A bind mount shows the host directory exactly as it is, and if Docker had to create that directory it made it owned by root. Run docker compose exec <service> id to see the numeric id the container uses, then sudo chown -R <uid>:<gid> the host directory, or set user: "1000:1000" on the service.
Where does Docker store named volumes on disk?
With the default local driver they live under /var/lib/docker/volumes/<volume>/_data, and docker volume inspect <volume> prints the exact Mountpoint. Read it if you need to check something, but write to it only through a container, because editing as root on the host changes ownership in ways the container will not expect.
How do I back up a named volume?
Run a short-lived container with the volume and a host directory both mounted, then archive from one to the other with docker run --rm -v myvol:/data:ro -v "$PWD":/backup alpine tar czf /backup/myvol.tar.gz -C /data .. For a database, dump with the database's own tool instead of copying live files, because a file copy taken while writes are in flight can restore into a corrupt state.
Does docker compose down delete my volumes?
docker compose down removes containers and networks and leaves named volumes in place. docker compose down -v also deletes every named volume the project declares, permanently. Bind mounts are never removed by either command, because that directory belongs to the host and not to Docker.