Choosing Between Bind Mounts and Named Volumes
Postgres in the local stack logs could not fsync file "base/16384/2601": Invalid argument and occasionally corrupts after a laptop sleeps, because its data directory is a bind mount into the project folder; npm install inside the container takes six minutes on macOS; and a developer who ran git clean -fdx wiped their local database along with build output. Each of these comes from using the wrong storage type for a directory. Compose offers three — bind mounts, named volumes and tmpfs — and each fits different data. This page assigns each directory in a typical stack to the right one, with measurements, as part of volume mounting and hot-reload optimization.
The rule of thumb: bind-mount only what you edit on the host; keep everything the container owns in volumes.
Diagnostic
List every mount in the stack with its type and source, and time a write-heavy operation on each:
#!/usr/bin/env bash
set -euo pipefail
docker compose config --format json | jq -r '.services | to_entries[] | .key as $s | (.value.volumes // [])[] | "\($s)\t\(.type)\t\(.source // "-")\t\(.target)"'
for target in /app/node_modules /var/lib/postgresql/data; do
svc=$( [ "$target" = /app/node_modules ] && echo web || echo db )
docker compose exec -T "$svc" sh -c "start=\$(date +%s%N); for i in \$(seq 1 2000); do echo x > $target/.probe-\$i; done; rm -f $target/.probe-*; echo \"$target: \$(( (\$(date +%s%N) - start) / 1000000 )) ms\"" || true
done
Expected bad output on macOS:
web bind /Users/dev/src/shop/web /app
web bind /Users/dev/src/shop/web/node_modules /app/node_modules
db bind /Users/dev/src/shop/.data/pg /var/lib/postgresql/data
/app/node_modules: 8420 ms
/var/lib/postgresql/data: 7910 ms
Dependencies and database data live on bind mounts, so every write crosses the host–VM boundary, and the database files sit in the project directory where git clean can delete them.
Root cause
A bind mount maps a host path into the container. On Linux that is nearly free; on macOS and Windows the host path lives outside the Linux VM, so every file operation goes through a file-sharing layer (virtiofs, gRPC-FUSE or 9P) that is much slower for metadata-heavy work and does not always implement filesystem semantics databases rely on, such as fsync behaviour and file locking. A named volume lives inside the VM's own filesystem, managed by Docker, so it is fast everywhere and behaves like a normal Linux disk. tmpfs lives in memory and disappears when the container stops. Choosing by habit — bind-mounting everything because it is visible — puts database files and dependency trees on the slowest, least faithful storage, and puts state that should survive a git clean inside the working tree.
Resolution
- Classify each directory by who edits it and whether it must survive:
- Apply it in Compose:
services:
web:
build: ./web
volumes:
- ./web:/app
- web-node-modules:/app/node_modules
- web-cache:/app/.cache
db:
image: postgres:16.4
environment:
POSTGRES_PASSWORD: postgres
volumes:
- db-data:/var/lib/postgresql/data
volumes:
web-node-modules:
web-cache:
db-data:
The named volume at /app/node_modules shadows that path inside the bind mount, so dependencies installed in the container never touch the host. The database data lives in db-data, outside the project folder.
- Use tmpfs where data should never survive, such as a CI test database:
services:
db:
tmpfs:
- /var/lib/postgresql/data:size=512m
Put this in the CI override so local data still persists.
- Move existing data into volumes once, by dumping and restoring rather than copying files:
#!/usr/bin/env bash
set -euo pipefail
docker compose exec -T db pg_dumpall -U postgres > /tmp/local-dump.sql
docker compose down
git checkout -- compose.yaml && docker compose up -d --wait db
docker compose exec -T db psql -U postgres < /tmp/local-dump.sql
rm -rf .data/pg
Expected output
$ docker compose config --format json | jq -r '.services | to_entries[] | .key as $s | (.value.volumes // [])[] | "\($s)\t\(.type)\t\(.target)"'
web bind /app
web volume /app/node_modules
web volume /app/.cache
db volume /var/lib/postgresql/data
$ docker compose exec -T web sh -c 'cd /app && time -p npm ci >/dev/null' 2>&1 | grep real
real 41.20
Only source code is bind-mounted; dependencies, caches and the database are in named volumes. npm ci drops from about six minutes to about forty seconds on macOS, and the database survives git clean -fdx.
The database errors disappear as well. Postgres on a named volume sees an ordinary ext4 filesystem inside the VM with the fsync and locking semantics it expects, so the sleep-related corruption reports stop. That is a correctness improvement, not only a performance one, and it is the strongest argument for never bind-mounting database data directories on macOS or Windows.
Prevention
Lint Compose files for bind-mounted data directories — any bind mount whose target is a known data path (
/var/lib/postgresql/data,/data/db,/var/lib/mysql,node_modules) should fail review.Name volumes explicitly in the
volumes:section rather than relying on anonymous volumes, so they are easy to inspect, back up and remove.Document the reset path —
docker compose down -vremoves named volumes — so developers know how to start fresh without deleting files by hand.
Platform caveats
macOS (Docker Desktop): the gap between bind mounts and volumes is largest here; with virtiofs it narrows but remains several times for metadata-heavy work. Consider Compose watch to avoid bind-mounting source at all.
WSL2: bind mounts from the Linux filesystem are fast; from
/mnt/cthey are slow and inotify events are unreliable. Named volumes are fast in both cases.
Linux: bind mounts are near-native speed, but named volumes still avoid ownership mismatches between the host user and container users.
Apple Silicon (ARM64): no architecture-specific differences; the storage layer is the same.
Rollback
Switch individual directories back to bind mounts by restoring the Compose file; data in named volumes remains until removed:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- compose.yaml
docker compose up -d --force-recreate
docker volume ls --filter "name=$(basename "$PWD")"
Frequently Asked Questions
Why is npm install so slow in a container on macOS?
Because node_modules is on a bind mount, so every one of thousands of small writes crosses the file-sharing layer between macOS and the Docker VM. Put node_modules in a named volume.
Is it safe to keep database data in a bind mount?
On Linux it usually works; on macOS and Windows it is slow and can hit fsync and locking problems that corrupt data. Named volumes avoid both and keep data out of the project directory.
How do I see or back up data in a named volume?
Use docker run --rm -v <volume>:/data alpine tar -czf - -C /data . > backup.tgz, or better, use the database's own dump tool, which produces a portable backup.
What is the difference between a named and an anonymous volume?
A named volume has a stable name you declare and reuse; an anonymous volume gets a random name and is easy to lose track of. Declare named volumes explicitly for anything worth keeping.