Docker Compose Volumes Explained: Named Volumes vs Bind Mounts
Docker compose volumes solve one problem, and it's a problem every self-hoster runs into eventually: when a container gets torn down, where does its data go? Get the answer wrong and you'll watch a WordPress database or a Postgres instance vanish right after docker compose down, even though the official documentation insists that command leaves volumes alone. It usually does. What actually destroys data almost always turns out to be one of three things: an anonymous volume the image created without telling you, a renamed project folder, or a stray -v flag in a script. This guide walks through both mount types, shows exactly where Docker keeps your data on disk, and ends with a backup routine you can paste into a cron job today.
Before you start, you'll want a terminal (SSH works fine if you're on a VPS or home server), Docker and Docker Compose already installed, and a compose.yaml file to experiment with. If you don't have one yet, the free Compose generator on this site writes the volume block for you, which is worth keeping open in a second tab while you read.
Named Volumes vs Bind Mounts: Who Owns Your Data
Every mount in a Compose file answers the same underlying question: who is responsible for this data, Docker or you? A named volume is storage that Docker creates and manages entirely on its own, tucked away inside its own corner of the filesystem. A bind mount is a folder or file that already exists on your host (the machine running Docker itself), and Compose just points the container at it directly.
Picture a named volume as a storage locker at a self-storage facility. You don't choose which unit you get and you don't see the building's layout, but the facility guarantees your things are there when you come back, even if your house (the container) burns down. A bind mount is closer to keeping the same box in your own garage: you know exactly where it is and you can rearrange it yourself, but you're also the one responsible for keeping the garage locked and dry. The locker analogy breaks a little once you realize you actually can walk into Docker's storage room and inspect the files directly, as root, on the host filesystem. It's not sealed. Docker just manages the address for you, so you rarely have a reason to go looking.
| Question | Named volume | Bind mount |
|---|---|---|
| Who manages the storage | Docker | You, via the host filesystem |
| Where it lives | Inside Docker's own directory tree | Wherever you point it on the host |
| Survives docker compose down | Yes | Yes (it's just a host folder) |
| Survives docker compose down -v | No, if declared in the file | Yes, always |
| Editable directly from the host | Awkward, needs root | Yes, like any other folder |
| Typical permissions trouble | Rare | Common: UID mismatches |
| Best for | Databases, uploads, anything Docker should own | Source code in development, config files you edit by hand |
Docker Compose Syntax: How One Character Decides the Mount Type
The short syntax for a volume line follows the pattern SOURCE:CONTAINER_PATH, with an optional access mode tacked on the end. Docker decides which type you meant based on one thing: whether the source looks like a path. If it contains no slash, Compose treats it as a named volume. If it starts with /, ./ or ../, or contains a slash anywhere, Compose treats it as a bind mount. The official Compose Specification specifically recommends starting relative paths with ./ so nobody, including future you, mistakes a folder for a volume name.
services:
db:
image: postgres:18
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql
- ./init-scripts:/docker-entrypoint-initdb.d:ro
volumes:
db-data:
In that example, db-data has no slash, so it's a named volume. ./init-scripts starts with a dot and a slash, so it's a bind mount, and the trailing :ro makes it read only, meaning the container can read the files but never write or delete them on your host. That ${DB_PASSWORD} placeholder pulls from a .env file rather than sitting in the Compose file as plain text; this site's guide to Docker Compose environment variables covers exactly how that substitution works, worth a read before you type real credentials into any file that might end up committed to git.
Notice the second block, the top-level volumes: key with db-data: underneath it. That's not optional decoration. Any named volume you reference inside a service has to be declared there too, or Compose reports an error about an undefined volume and refuses to start the stack. This is exactly the kind of typo the site's validator is built to catch, along with the reverse mistake: a bind mount path that's missing its ./ prefix and gets silently misread as a volume name instead.
Where Docker Stores Named Volumes
On a Linux host, named volumes live under /var/lib/docker/volumes/<name>/_data. You don't need to memorize that path, because Docker will tell you exactly where a given volume sits:
docker volume inspect db-data
The output includes a Mountpoint field showing the real path, something like this:
[
{
"Name": "myproject_db-data",
"Driver": "local",
"Mountpoint": "/var/lib/docker/volumes/myproject_db-data/_data",
"Scope": "local"
}
]
Notice the name isn't just db-data, it's myproject_db-data. Compose prefixes every named volume with the project name, which defaults to the directory your Compose file lives in, unless you set name: explicitly or mark the volume external: true. Keep that prefix in mind; it matters a lot in the next section. One more caveat if you develop on a Mac or Windows machine: Docker Desktop runs everything inside a small Linux virtual machine, so that path exists inside the VM, not directly on your Mac or Windows filesystem. You can still browse volume contents through Docker Desktop's own UI, just not by opening Finder or Explorer to that folder.
One behavior worth knowing before you're confused by it later: if you mount an empty named volume onto a container directory that already has files baked into the image (this is exactly how the official postgres and mysql images initialize their databases on first run), Docker copies those files into the volume automatically. Mount a volume that already has data, or any bind mount, onto a directory with existing image files, and it works the other way: the image's files are hidden underneath the mount until you remove it. Full details on this copy behavior are in Docker's volumes documentation.
Why Your Database Data Disappeared After docker compose down
This is the question that brings most beginners to this article, usually while panicking a little. Here's the short version: docker compose down, by itself, does not remove named volumes. It only stops containers and removes the networks the file defined.
What down Actually Removes
According to Docker's own reference for the down command, plain docker compose down removes the containers for your services and the networks defined in the file, nothing more. Add --volumes or its shorthand -v, and it also removes any named volumes declared in the file, plus any anonymous volumes attached to those containers. Volumes marked external: true are never touched, with or without -v, because Compose considers their lifecycle outside its responsibility. So if you never typed -v, something else deleted your data. There are three usual suspects.
Cause 1: An Anonymous Volume From the Wrong Mount Path
Here's the part almost nobody warns you about. Docker images can declare a VOLUME instruction in their Dockerfile, and it's binding whether you asked for it or not. According to the Dockerfile reference, that instruction marks a directory as needing externally mounted storage. If you don't mount anything there yourself, the container engine quietly creates an anonymous volume, one of those unnamed entries with a 64-character hash for a name that fills up your docker volume ls output.
The official Postgres image is the textbook case, and it recently got more confusing. Up through Postgres 17, the image declares its volume at /var/lib/postgresql/data. Starting with Postgres 18, the layout changed: the actual data directory is now version-specific, and the declared volume moved up to /var/lib/postgresql so that in-place major version upgrades can work. That means a tutorial written for Postgres 16 or 17 will mount the wrong path on a Postgres 18 image.
# Wrong on postgres:18, correct on postgres:17 and earlier
services:
db:
image: postgres:18
volumes:
- db-data:/var/lib/postgresql/data
# Correct on postgres:18
services:
db:
image: postgres:18
volumes:
- db-data:/var/lib/postgresql
Mount the old path on the new image and your named volume sits somewhere Postgres never writes to. The actual data lands in an anonymous volume at the real VOLUME path instead, because nothing else claims it. Everything looks fine while the container is running. Then someone runs down -v during a routine cleanup, the anonymous volume gets swept away with it, and the named volume that survives is, and always was, empty. This exact scenario plays out with other images too: one Docker forum thread traces a "vanishing" Mongo database to a Compose file that mounted /db/data instead of the correct /data/db, a one-character typo that quietly created an anonymous volume for the real data. Always check an image's own documentation for its exact data path before writing the volume line; verify it on Docker Hub rather than trusting a blog post, this one included.
Cause 2: A Renamed Project Folder
Remember that project-name prefix from the last section? It's derived from the name of the folder your Compose file sits in, unless you override it. Rename that folder, even just fixing a typo or moving from myapp to my-app, and Compose now considers this a different project. It creates a brand new, empty volume with the new prefix. Your old volume, and its data, is still sitting on disk under the old name; Compose just isn't looking at it anymore.
The fix is to stop relying on the folder name entirely. Set an explicit name in the top-level volumes block (name: myapp_db-data), or set the COMPOSE_PROJECT_NAME environment variable so it stays constant no matter what the folder is called. If you've already renamed a folder and you're staring at empty data, run docker volume ls first; your original volume is very likely still there under its old prefix, just orphaned rather than gone. You can point a fresh Compose file at it by name, or copy its contents into the new one using the backup and restore steps further down.
Cause 3: Someone Ran down -v
Sometimes the explanation is simply that. A deploy script with -v left in from testing, a GUI tool with a "reset everything" button that's more aggressive than it looks, a copy-pasted command from a forum thread. If a volume genuinely must never be deleted by Compose, no matter what flags get passed, mark it external: true in the top-level volumes block. Compose will refuse to remove it, full stop, and will error out if it doesn't already exist rather than silently creating a new empty one. For routine restarts where you don't need to remove containers at all, docker compose stop followed by docker compose start sidesteps this entire category of accident.
Bind Mount Permissions in Docker Compose
If named volumes rarely cause permission headaches, bind mounts are where most beginners lose an afternoon. The core issue is a UID mismatch: a UID, or user ID, is just the number Linux uses internally to track file ownership. Your login user has one, and the process running inside the container has one too, and they're often not the same number.
Here's where it usually goes wrong. If the host folder you're bind mounting doesn't exist yet, Docker creates it for you automatically, and it creates it as root, because that's the user the Docker daemon runs as. If the process inside your container runs as a non-root user (which it should, for security), that process now can't write into a folder owned by root. You'll see "permission denied" in your container logs, and it will look like the application is broken when the mount is the actual problem, as described in Docker's bind mount documentation.
A few ways to fix it, roughly in order of how often each one applies:
- Create the host directory yourself ahead of time and
chownit to match the UID the container process runs as, before you ever start the container. - Add a
user:field to the service in your Compose file, setting it to the UID and GID your application expects, so the container process matches the host ownership instead of the other way around. - Mount the folder read only with
:rowhen the container never needs to write there, which sidesteps the whole problem. - On Fedora, RHEL, or other SELinux-enforcing systems, add
:zto share a mount between multiple containers, or:Zto keep it private to one. Be careful with:Zon a directory something else on the system also uses, such as/home; it relabels the whole tree and can make unrelated services unable to read their own files.
Database images add one more wrinkle. Postgres's own image documentation notes that if you run the container with a custom --user on a bind mount, the owner of the data directory has to match that UID, and the user needs an entry in the container's own /etc/passwd for initialization to succeed. It's the container's own database user that matters here, not your personal login account on the host, so don't assume your host UID needs to match some specific number without checking the image's documentation first.
One more thing worth knowing if you've tested a stack on a laptop before deploying it: Docker Desktop on macOS and Windows translates file ownership between the VM and your host automatically, so UID mismatches mostly don't show up there. Deploy the identical Compose file to a Linux VPS, where there's no translation layer, and the same bind mount can suddenly throw permission errors. "Works on my Mac, breaks on the VPS" is almost always this.
How to Back Up and Restore a Named Volume
A volume that only exists on one server isn't really a backup strategy, it's a countdown. Here's a routine that works for any named volume, database or otherwise.
- Confirm the exact volume name. Run
docker volume lsin your terminal. You should see an entry likemyproject_db-datain the list. If nothing matches what you expect, rundocker compose psfrom your project folder first to confirm the project name Compose is actually using. - If the volume holds a database, prefer a proper dump over a raw file copy, since it captures the data in a consistent state even while the database is running. For Postgres, run
docker compose exec db pg_dump -U postgres mydb > backup.sql. You should see a newbackup.sqlfile appear in your current folder containing readable SQL statements. An empty file usually means the container isn't running, or the database name or username doesn't match what's in your environment variables. - For a full volume copy, such as WordPress uploads or configuration files, stop the container first so nothing writes mid-copy:
docker compose stop. Your terminal should return to the prompt with no error, anddocker compose psshould show the service as stopped. - Run a throwaway Alpine container that mounts your volume read only alongside your current folder, and archive it:
You should see adocker run --rm -v myproject_db-data:/volume:ro -v "$(pwd)":/backup alpine tar czf /backup/db-data-backup.tar.gz -C /volume .db-data-backup.tar.gzfile appear in your working directory, sized well above a few kilobytes. A near-empty archive means the volume name was wrong, or the volume genuinely has nothing in it yet. - To restore, create a fresh volume and extract the archive into it:
No output means success. Confirm the files landed correctly withdocker volume create db-data-restored docker run --rm -v db-data-restored:/volume -v "$(pwd)":/backup alpine sh -c "tar xzf /backup/db-data-backup.tar.gz -C /volume"docker run --rm -v db-data-restored:/volume alpine ls /volume, which should list the same files you had before. - Point your Compose file at the restored volume, either by renaming it back or setting
name: db-data-restoredunder the top-level volumes key, then rundocker compose up -d. If the service behind this volume is a public-facing site, a quick check with httpcheck.tools confirms it's genuinely back up (a 200, not a 5xx status code) before you tell anyone the incident is over.
Named Volume or Bind Mount? A Quick Decision List
When you're staring at a blank volume line and not sure which type to reach for, this covers most real situations.
- Databases and application uploads (Postgres, MySQL, Mongo, WordPress media): a named volume, essentially always. Let Docker own it and back it up on a schedule (and if the uploads are images, shrinking them keeps that volume and its backups small).
- Source code you're actively editing during development: a bind mount, so your editor on the host and the process in the container see the exact same files, instantly, with no rebuild.
- Production deployments where you need a specific, known location on disk for your own backup tooling: either a named volume with
driver_optspointing at a fixed device path, or a bind mount you manage with your own scripts and permissions.
The Compose generator on this site declares named volumes under the top-level volumes: key automatically, which is the exact step beginners forget and the reason the file it hands you just works the first time. If you've already got a file and want a second opinion, running it through the validator flags undeclared volumes and bind mounts missing their ./ prefix before you ever run docker compose up. For the broader mechanics of services, networks, and restart policies, the site's how-to-use-docker-compose guide is the natural companion to this one.
Frequently Asked Questions
What is the difference between a named volume and a bind mount?
A named volume vs bind mount comes down to ownership. A named volume is storage Docker creates and manages itself, referenced by a plain name with no slashes, and Docker decides exactly where it physically lives. A bind mount points at a folder or file you already control on the host, referenced with a path that starts with /, ./, or ../. Named volumes are the safer default for anything Docker should be responsible for, like database files; bind mounts make sense when you need to edit or inspect the files directly from the host, such as source code during development.
Where does Docker store named volumes?
On a Linux host, Docker stores named volumes under /var/lib/docker/volumes/<name>/_data, and you can confirm the exact path for any volume by running docker volume inspect <name> and checking its Mountpoint field. On Docker Desktop for macOS or Windows, that path exists inside Docker's internal Linux VM rather than directly on your host filesystem, so you won't find it by browsing Finder or File Explorer; use Docker Desktop's own volume browser or a throwaway container instead.
Why did my database data disappear after docker compose down?
Plain docker compose down, without the -v flag, does not remove named volumes; it only stops containers and removes networks. If your data still vanished, the most common causes are: an anonymous volume created because a named volume was mounted at the wrong container path (a frequent issue with database images like Postgres, whose declared volume path changed between versions 17 and 18), a renamed project folder pointing Compose at a brand new empty volume instead of the old one, or someone running down -v in a script or deployment tool. Checking docker volume ls right after the incident usually shows whether the old volume is still sitting there under a different name.
How do I back up a Docker volume?
For databases, the most reliable backup is a native dump tool run while the database is live, such as docker compose exec db pg_dump -U postgres mydb > backup.sql for Postgres. For a general-purpose volume, stop the container using it, then run a temporary Alpine container that mounts the volume read only alongside your current folder and archives it with tar, for example docker run --rm -v myvolume:/volume:ro -v "$(pwd)":/backup alpine tar czf /backup/backup.tar.gz -C /volume .. Restore by creating a fresh volume and extracting the archive into it with a similar one-off container, and always test the restore on a throwaway volume before you need it for real.