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.

2000 Small Writes by Storage Type on macOS Bar chart comparing time for two thousand small file writes on a bind mount, a named volume and tmpfs. 2000 Small Writes by Storage Type on macOS bind mount 8.4 s named volume 0.6 s tmpfs 0.1 s
Measured with Docker Desktop and virtiofs; named volumes and tmpfs stay inside the VM.

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

  1. Classify each directory by who edits it and whether it must survive:
Storage Type by Directory Table assigning source, dependencies, database data, caches and test data to bind mounts, volumes or tmpfs. Storage Type by Directory Directory Edited on host Survive restart Use src/ yes yes bind mount or watch node_modules, .venv no yes named volume database data no yes named volume build caches no nice to have named volume CI test database no no tmpfs
Bind mounts are for files you edit on the host; everything the container owns goes in volumes.
  1. 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.

  1. 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.

  1. 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
Picking a Storage Type Decision diagram choosing bind mount, named volume or tmpfs from how a directory is used. Picking a Storage Type Do you edit these files on the host? yes bind mount (or watch) no, must persist named volume no, throwaway tmpfs
Ask who writes the files and whether they must outlive the container.

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

  1. 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.

  2. Name volumes explicitly in the volumes: section rather than relying on anonymous volumes, so they are easy to inspect, back up and remove.

  3. Document the reset pathdocker compose down -v removes 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/c they 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.