Docker Volumes vs Bind Mounts, When to Use Each
TL;DR — Volumes: Docker-managed, persistent, work everywhere. Use for DB data, cache, anything the host doesn’t need to read. Bind mounts: map host path to container path, code-syncing. Use for live source code, config files. tmpfs: in-memory, fast, ephemeral. Use for cache directories.
After Compose Watch, the underlying storage primitives. Three mechanisms; each is right for a specific use case. Mixing them is how Mac users end up with slow Docker dev.
Volumes
Docker-managed named storage. Lives at /var/lib/docker/volumes/ (Linux) or inside the Docker VM (Mac/Windows).
services:
postgres:
image: postgres:14-alpine
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
Properties:
- Persistent across
docker compose down(gone ondown -v) - Host doesn’t need to know about the path
- Performance: native filesystem speed on Linux + Mac
- Cleanup:
docker volume prune
Use for: database data, cache that needs to persist, large generated files (npm packages, vendor dirs in service workflows).
Bind mounts
Host path mapped to container path.
services:
api:
build: .
volumes:
- ./src:/app/src
- ./config.json:/app/config.json
Properties:
- Host and container see the same files
- Performance: native on Linux. Slow on Mac (file events cross VM boundary).
- Cleanup: nothing to clean (host owns the data)
Use for: source code during dev, config files you want to edit on the host, log files for tail-from-host.
tmpfs (in-memory)
A volume that lives only in RAM.
services:
api:
tmpfs:
- /tmp
- /app/cache:size=100m
Properties:
- Fast: RAM speed
- Ephemeral: gone on container stop
- Limited by memory
Use for: scratch directories, build cache that doesn’t need persistence, /tmp for things that shouldn’t survive.
Performance on Mac specifically
Docker Desktop on Mac runs in a VM. Bind mounts cross the VM boundary; volumes don’t. Concrete numbers from running a test on M1 Pro:
| Operation | Bind mount | Volume |
|---|---|---|
npm install (cold) |
42s | 9s |
npm test (read-heavy) |
18s | 4s |
| Vite cold start | 8s | 1.5s |
The ratio is 4-5× across the board. On Linux there’s no boundary; bind mount and volume perform the same.
Practical implication: on Mac, use volumes for things you don’t need to edit on the host. The classic node_modules dance is the canonical Mac performance issue.
The node_modules pattern
You want:
- Source code editable on the host
- node_modules built inside the container (which can install platform-specific binaries)
- Both available to the running container
services:
bff:
build: ./services/bff
volumes:
- ./services/bff:/app
- /app/node_modules
The bind mount maps the whole bff dir to /app. The anonymous volume on /app/node_modules shadows the bind for that specific path — the container’s node_modules wins.
Result: host edits source; container has its own node_modules.
Same pattern for vendor, .venv, target:
volumes:
- ./services/api:/app
- /app/vendor # PHP
- /app/.venv # Python
- /app/target # Rust
Volume drivers
Volumes can use different storage backends:
volumes:
postgres-data:
driver: local # default
driver_opts:
type: nfs
o: addr=10.0.0.1,rw
device: ":/data/postgres"
99% of Compose use is local. Cross-machine NFS / cluster volumes are edge cases.
External volumes
For data that should outlive the compose stack:
services:
postgres:
volumes:
- persistent-data:/var/lib/postgresql/data
volumes:
persistent-data:
external: true # Compose won't create or delete
name: prod_postgres_data
Compose treats the volume as pre-existing. docker compose down -v won’t delete it. Useful for staging environments where you want to swap the stack without losing data.
Bind mount gotchas
Permissions: container user vs host user. If the container creates files as UID 1000 and your host user is UID 501, you can’t edit them from the host. Solutions:
- Run container as host UID:
user: "${UID}:${GID}"(set in .env) - chown on container start (init script)
- Same UIDs across team (annoying)
Empty host directory clobbers container content: if your Dockerfile installs node_modules at /app/node_modules and you then bind-mount the host’s empty ./app to /app, you lose node_modules. The anonymous-volume pattern above fixes this.
Mac fsevents lag: bind mounts on Mac report file changes with a 100-200ms delay. Tools using polling (older Webpack) see it fine; tools using inotify directly (some Rust tooling) might not.
When to use each — quick reference
| Use case | Choose |
|---|---|
| Postgres / MySQL data | Volume |
| Source code (dev) | Bind mount (+ anonymous volume for deps) |
| Source code (CI / prod build) | Image layer, not mount |
| Config files edited locally | Bind mount |
| Cache directory (no persist) | tmpfs |
| Shared state across containers | Volume |
| Logs you want to tail from host | Bind mount to a logs/ directory |
Common Pitfalls
Bind-mounting the entire app dir without anonymous volume for deps. Container’s deps get clobbered. Add the anonymous volume.
Storing DB data in a bind mount. Works on Linux; slow on Mac; permissions get weird. Use named volume.
Forgetting :ro for read-only mounts. Container can mutate your config file. :/app/config.yml:ro makes it readable only.
Volume names that collide across stacks. Two projects with same volume names share data. Use compose name: to scope.
Production using bind mounts. Production should use volumes or no storage; bind mounts assume the host filesystem persists, which it doesn’t in container orchestrators.
tmpfs for things that should persist. Container restart = data gone. Reread your assumptions.
Wrapping Up
Three storage primitives, three use cases. Default: volume for data, bind mount for source, tmpfs for ephemera. On Mac, prefer volumes whenever the host doesn’t need to see the files. Monday: Compose networking — the connection between containers.