Docker Compose Volumes: Mount, Persist & Share Data
Master docker compose volumes with working syntax, named volumes, bind mounts, and fixes for the most common data-loss and permission errors.
FlowQL Team
AI Search Optimization Experts
You spin up a Postgres container with Docker Compose, insert some test data, then run docker compose down. When you bring it back up, everything is gone. No error, no warning — the data just vanished.
This is the most common Docker trap for developers who are new to containerized workflows. The fix is volumes, and once you understand the three types and when to use each, the whole system clicks into place.
This guide covers the full docker compose volumes syntax with real working examples, the difference between named volumes and bind mounts, how to share data between services, and how to fix the permission and mount errors that catch everyone at least once.
Why Volumes Matter in Docker Compose
Containers are ephemeral by design. Every container has a writable layer on top of its image, but that layer is thrown away the moment you run docker compose down (with the --volumes flag) or docker rm. Without volumes, any file your app writes during runtime — database rows, uploads, generated files — disappears on the next teardown.
Volumes break the coupling between your container's lifecycle and your data's lifecycle. They live outside the container, managed either by Docker itself or anchored to a path on your host machine. Your container can be rebuilt, upgraded, or swapped out entirely, and the data stays put.
Docker's official volumes documentation lays out the storage driver internals if you want to go deeper. For most application development, you need to understand three things: named volumes, bind mounts, and tmpfs mounts.
The Three Types of Docker Volumes
Before writing any docker-compose.yml, know which type you need. Choosing the wrong one is the root cause of most data-loss and performance issues.
| Type | Managed by | Persists data | Best for | |---|---|---|---| | Named volume | Docker | Yes | Databases, persistent app data | | Bind mount | Host OS | Yes (host files) | Local dev, live code sync | | tmpfs mount | Memory (RAM) | No | Secrets, temp cache in prod |
Named volumes are stored under /var/lib/docker/volumes/ on Linux. Docker handles creation and cleanup. They're portable across hosts when used with docker volume export.
Bind mounts point directly at a directory on your machine. Whatever is in that directory is what the container sees — in real time. Ideal for development where you want the container to pick up file edits without rebuilding the image.
tmpfs mounts never hit disk. The data lives in RAM and is gone the moment the container stops. Use them for sensitive environment data or scratch space where persistence would be a liability.
Named Volumes: The Default Choice
How do named volumes work in Docker Compose?
Named volumes are the safest choice for any data that must survive container restarts. You declare the volume in two places: under the service's volumes: array (where you set the mount path inside the container) and under the top-level volumes: key (where you register it with Docker). If you skip the top-level declaration, Compose will throw an error.
Here is a minimal Postgres setup with a named volume:
# docker-compose.yml — named volume for Postgres data
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
# Syntax: <volume-name>:<container-path>
- db-data:/var/lib/postgresql/data
ports:
- "5432:5432"
# Top-level volumes key — required for named volumes
volumes:
db-data:
Run docker compose up -d, insert data, then run docker compose down. Run docker compose up -d again and the data is still there. The db-data volume lives at /var/lib/docker/volumes/yourproject_db-data/_data on the host — Compose prefixes the project name automatically.
To inspect what Docker knows about the volume:
# List all volumes on the host
docker volume ls
# Inspect a specific volume's mount point
docker volume inspect yourproject_db-data
Named volumes are also the right choice when you run Postgres inside Docker for local development — see the patterns in our Postgres Docker setup guide for a complete working stack with pgAdmin wired up alongside it.
Docker Compose volumes syntax reference
The full syntax for a volume entry under a service supports several options beyond the shorthand string form:
services:
app:
image: myapp:latest
volumes:
# Shorthand: <source>:<target>
- app-data:/app/data
# Long form — more explicit, supports all options
- type: volume
source: app-data
target: /app/data
read_only: false # set true to prevent container writes
volume:
nocopy: true # don't copy container data into volume on first run
volumes:
app-data:
driver: local # default; swap for nfs, overlay2, etc.
driver_opts:
type: none
o: bind
device: /mnt/external-drive # mount an NFS or external path as a named volume
The nocopy: true flag is worth knowing. By default, when Docker creates a named volume and mounts it into a container, it copies the contents of the container's directory at that path into the volume on the first run. For database images that already handle initialization themselves (like the official Postgres image), nocopy: true avoids unnecessary copying.
Bind Mounts: Sync Your Local Code
What is a bind mount in Docker Compose?
A bind mount maps a path on your host machine directly into the container at a specified path. Changes on the host appear in the container immediately, and vice versa. This makes bind mounts the standard approach for local development — your editor saves a file, and the running container (with hot reload) picks it up without a rebuild.
# docker-compose.yml — bind mount for live code sync
services:
api:
build: .
volumes:
# Syntax: <host-path>:<container-path>
- ./src:/app/src # sync local ./src into container
- ./config:/app/config:ro # :ro = read-only inside container
ports:
- "3000:3000"
command: npm run dev
The ./src path is relative to the directory containing your docker-compose.yml. You can also use absolute paths:
volumes:
- /home/myuser/projects/myapp/src:/app/src
Relative paths are almost always better for team projects — they work on any developer's machine without modification.
Watch out for node_modules. A classic bind mount trap: if you mount your entire project root into the container but the container has its own node_modules built during the image build, the bind mount will shadow (overwrite) the container's node_modules with your host's version — or an empty directory if you don't have node_modules locally. The fix is an anonymous volume on node_modules:
services:
web:
build: .
volumes:
- .:/app # bind mount entire project
- /app/node_modules # anonymous volume protects node_modules
ports:
- "3000:3000"
command: npm run dev
The second volume entry — /app/node_modules with no source — creates an anonymous volume. Docker uses the directory from the image build and keeps it isolated from the host bind mount layered on top.
Bind mounts are also handy when debugging environment variable issues in containerized deploys. If you're seeing a service fail to start because a config file isn't where it expects, temporarily bind-mounting a local config file into the container narrows down whether the problem is the file content or the build process. The same approach applies when debugging Vercel build failures that you can't reproduce in CI — mounting local build output into a test container gets you closer to the production environment.
Sharing Volumes Between Services
How do you share a volume between two Docker Compose services?
Declare the volume at the top level once, then reference it in as many services as need it. Both containers will read and write to the same storage on disk.
A common use case: a background worker that processes files uploaded by a web service.
# docker-compose.yml — shared volume between web and worker
services:
web:
build: ./web
volumes:
- uploads:/app/uploads # web writes uploaded files here
ports:
- "8080:8080"
worker:
build: ./worker
volumes:
- uploads:/data/uploads # worker reads from the same storage
depends_on:
- web
volumes:
uploads:
driver: local
The paths inside the container don't have to match — web sees the volume at /app/uploads while worker sees it at /data/uploads. What matters is that both point to the same uploads volume name.
Coordination warning: Docker volumes don't implement any file locking at the storage level. If both services write to the same file simultaneously, you'll get data corruption. Design your services so only one writes to a given file or directory, or implement locking in your application logic.
You can also share a volume with a service defined in a separate Compose file by using external: true:
# docker-compose.worker.yml — consuming a volume defined elsewhere
volumes:
uploads:
external: true # Docker expects this volume to already exist
This is useful in multi-project setups where a shared data layer is managed by one Compose stack and consumed by others.
If you're running Supabase in Docker alongside a custom service and hitting connection issues, the shared networking and volume patterns in our Supabase connection refused guide apply directly — both problems come from services not being able to reach each other through Docker's internal network.
Common Volume Errors and Fixes
"No such file or directory" on container start
This usually means the host path in a bind mount doesn't exist yet. Docker does not create the host directory automatically for bind mounts (unlike named volumes, which Docker creates on first run).
# Create the host directory before running Compose
mkdir -p ./data/uploads
docker compose up -d
Add the mkdir step to your project setup script or Makefile so it runs before docker compose up in fresh environments.
Permission denied writing to a volume
# Error you see in logs:
# open /app/data/output.log: permission denied
The container process runs as a non-root user (good security practice), but the mounted directory on the host is owned by root or a different UID. There are three clean fixes:
# Fix 1: Set the user in the Compose service config
services:
app:
image: myapp:latest
user: "1000:1000" # match the UID:GID of your host user
volumes:
- app-data:/app/data
# Fix 2: Fix ownership on the host directory (for bind mounts)
chown -R 1000:1000 ./data
# Fix 3: Fix ownership inside the container via Dockerfile
# In your Dockerfile:
# RUN mkdir -p /app/data && chown -R node:node /app/data
Check what UID your container process runs as with docker compose exec <service> id.
Volume data missing after docker compose down
If your data disappears after docker compose down, check two things:
- Are you using a named volume or a bind mount? If neither, your data was in the container's writable layer and is gone.
- Did you run
docker compose down -v? The-vflag explicitly removes named volumes. Omit it to keep volumes when tearing down containers.
# This destroys named volumes — data is gone
docker compose down -v
# This keeps named volumes intact
docker compose down
Recovering data from a dropped volume isn't always possible — see the Docker volume backup docs for backup strategies before you hit this in production.
Stale bind mount caching on macOS and Windows
On Docker Desktop for macOS and Windows, bind mounts go through a translation layer between the VM and your host OS. In older Docker Desktop versions this causes file changes on the host to not propagate to the container fast enough for hot-reload to work reliably.
The fix is the delegated or cached mount consistency flag (Docker Desktop 4.x) or the newer VirtioFS file sharing backend in Docker Desktop 4.6+:
services:
web:
volumes:
# Legacy fix for Docker Desktop < 4.6
- .:/app:delegated
# Better: enable VirtioFS in Docker Desktop preferences
# then remove the consistency flag — default performance is fast
Check your Docker Desktop version and enable VirtioFS under Settings → General if it's available. It eliminates the latency problem entirely.
"Volume already exists with different options"
When you change a named volume's driver or driver_opts after it already exists, Docker throws this error. The volume was created with the old options and won't be updated in place.
# Inspect the existing volume options
docker volume inspect yourproject_db-data
# Remove the volume (warning: destroys data — back up first)
docker volume rm yourproject_db-data
# Re-run Compose to recreate it with the new options
docker compose up -d
Back up your data before removing a volume. The Docker volume backup guide shows how to docker run --rm a temporary container to tar the volume contents to your host.
For database-specific data management patterns — especially around init scripts, migrations, and seed data in Dockerized Postgres — the RLS policy debugging guide covers how data access issues surface differently in containerized vs hosted Supabase environments.
Conclusion
Docker Compose volumes aren't complicated once you understand the three types and the two-part declaration pattern. Named volumes for any data that must survive restarts. Bind mounts for local dev where you need live sync. tmpfs for ephemeral secrets and scratch space. Declare named volumes in both the service config and the top-level volumes: key, never run docker compose down -v unless you intend to wipe data, and add the anonymous volume trick anytime you bind-mount a directory that shadows node_modules or similar.
The errors that bring developers to a stop — lost data, permission denials, stale cache on macOS — all have clean fixes once you know where to look. Docker's compose file volumes reference and the bind mounts documentation are the authoritative sources for every option covered here.
When the volume configuration is correct but your database container is still crashing, your app still can't reach its data store, or a migration is failing in a way the logs don't explain — that's when a second pair of eyes pays off faster than another hour of solo debugging. FlowQL connects you live with a senior engineer who has debugged this exact class of problem before. Thirty minutes, screen share, fixed — or you don't pay.
Still blocked?
This fix didn't work for your setup? Get a senior engineer on your screen in 30 minutes — fixed or refunded.
Reserve My Spot →Get a senior engineer on your screen.
30-minute live screen-share sessions with a vetted senior dev. Fixed or fully refunded — no questions asked.
No spam. Just a heads-up when sessions open.
Related Articles
How to Stop a Docker Container (3 Ways)
Learn how to stop a Docker container cleanly with docker stop, force-quit with docker kill, and freeze with docker pause — with real commands and output.
Run Postgres in Docker: The Complete Setup Guide
Learn how to run Postgres in Docker with docker run and Docker Compose. Covers env vars, volumes, ports, and common postgres docker errors.
Fix: supabase_internal_image_registry Error — Use Docker Hub When ECR Is Blocked
Getting 'supabase_internal_image_registry' errors when ECR is blocked? Set SUPABASE_INTERNAL_IMAGE_REGISTRY=docker.io to pull from Docker Hub. Works in terminal, docker-compose, and CI/CD.