background-shape
Docker Volumes vs Bind Mounts, When to Use Each
July 15, 2022 · 5 min read · by Muhammad Amal programming

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 on down -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.