background-shape
Docker article cover illustration on a gradient background
July 15, 2022 · 5 min read · by Muhammad Amal programming
Advertisement

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

Advertisement
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:

OperationBind mountVolume
npm install (cold)42s9s
npm test (read-heavy)18s4s
Vite cold start8s1.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 caseChoose
Postgres / MySQL dataVolume
Source code (dev)Bind mount (+ anonymous volume for deps)
Source code (CI / prod build)Image layer, not mount
Config files edited locallyBind mount
Cache directory (no persist)tmpfs
Shared state across containersVolume
Logs you want to tail from hostBind 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.

Advertisement