TL;DR: Use volumes for persistent app data, bind mounts for local dev, tmpfs for ephemeral secrets/cache, and understand your storage driver (usually
overlay2) for image layers. Back up volumes, version your schemas, and never store critical state inside a container’s writable layer.
Why container storage matters
If containers are the disposable paper cups of compute, storage is the ceramic mug you don’t want to lose. Teams often discover this the hard way: a container gets redeployed, a pod is rescheduled, or a VM is reclaimed—and the app’s uploads are gone. That’s why storage choices have outsized impact on persistence, performance, portability, security, and day‑2 operability (backups, migrations, disaster recovery). The right approach depends on whether your data is durable, shared, or ephemeral—and how you plan to move it through environments.
In this guide, we’ll move from fundamentals (how Docker stores bits) to practical options (volumes, bind mounts, tmpfs, devices) and finish with opinionated recommendations. Along the way, you’ll see why keeping state in the container’s writable layer is a ticking clock.
The building blocks
1) Image layers vs. the container writable layer
Think of an image as a read‑only blueprint assembled at build time: every RUN, COPY, and ADD contributes a layer. When you run a container from that image, Docker stacks a writable layer on top. Writes inside the container land here by default. It’s convenient for scratch space, logs, and temporary files—but it’s not durable. Delete the container, and the layer (and its data) vanish. You can bake those changes back into a new image with docker commit, but that’s a brittle way to handle application state and makes rollbacks tricky.
Rule of thumb: the writable layer is for throwaway runtime data. Anything you can’t afford to lose belongs in a mounted volume.
2) Storage drivers (how Docker stores layers)
Under the hood, a storage driver applies copy‑on‑write magic to combine read‑only layers and the writable layer into a single filesystem view. On modern Linux, overlay2 is the sensible default: it’s stable, fast, and space‑efficient. Advanced filesystems like ZFS or Btrfs add features (snapshots, quotas, compression), but they also require ops expertise and specific kernel modules. You rarely choose a driver per container; it’s a daemon‑level decision that affects every image on the host. Knowing which driver you’re on helps when troubleshooting weird I/O behavior or runaway disk usage.
Check your driver quickly:
|
1 2 |
docker info --format '{{.Driver}}' |
(how Docker stores layers)
- Common drivers:
overlay2(Linux default),btrfs,zfs,aufs(legacy),devicemapper(legacy),windowsfilter(Windows),lcow/wsl2(Windows + Linux containers). - overlay2: Fast, space‑efficient (copy‑on‑write), stable default on modern Linux.
- zfs/btrfs: Advanced features (snapshots, quotas, compression) but add admin overhead and kernel/filesystem requirements.
- You rarely choose this per‑container; it’s a daemon host setting. Know what you’re running for troubleshooting, performance tuning, and disk growth.
Options for container data
A) Volumes (recommended for persistent data)
Volumes are Docker’s first‑class answer to persistence. They live outside the container’s lifecycle, under Docker’s data directory (by default) or on a storage backend of your choice. Because Docker manages them, volumes travel well between environments and make backups straightforward.
A common pattern is a database container with a named volume for its data directory. You can tear down and recreate the container without touching the data. Need to migrate hosts? Stop the app, snapshot or tar the volume, restore on the new box, and you’re back in business.
Create and use a named volume:
|
1 2 3 4 5 6 |
docker volume create appdata docker run -d \ -v appdata:/var/lib/myapp \ --name myapp myimage:latest |
Pros: Portable, safe defaults, supports volume plugins, no brittle ties to host paths.
Cons: Requires a clear backup/restore process (simple, but easy to forget until it’s urgent).
When to use: Databases, application state, user uploads, anything that must persist across container restarts or host reboots.
B) Bind mounts (map host paths into container)
Bind mounts are a direct window into the host filesystem. For developers, that means instant code reloads and familiar tooling—edit locally, run in the container. In production, bind mounts can work for static content or one‑off scenarios, but they couple the container to host layout, ownership, and policies (SELinux/AppArmor), which can reduce portability.
Example for local dev:
|
1 2 3 4 |
docker run --rm -it \ -v $(pwd)/src:/app/src \ -p 3000:3000 node:20 bash |
On macOS and Windows, remember that file I/O crosses a VM boundary. If node_modules syncs feel sluggish, consider strategies like building inside the container, using volumes for heavy directories, or developer‑focused sync tools.
Pros: Transparent, perfect for dev workflows; can also mount individual config files (certs, overrides).
Cons: Host‑dependent; permission and labeling quirks; performance varies by OS/hypervisor.
When to use: Local development, mounting single configs, troubleshooting. For production data, prefer volumes.
C) tmpfs mounts (in‑memory, ephemeral)
Sometimes you want data that never hits disk. tmpfs mounts store files purely in RAM and disappear when the container stops. They’re fantastic for caches, ephemeral work directories, and short‑lived secrets. For security, pair tmpfs with mount options like nosuid and noexec to shrink the attack surface.
Example:
|
1 2 3 4 5 |
docker run --rm -it \ --tmpfs /run:rw,noexec,nosuid,size=64m \ --tmpfs /tmp:size=256m \ alpine |
Pros: Very fast; reduces data remanence; great for sensitive or transient files.
Cons: Volatile and bounded by RAM; not suitable for durable state.
When to use: Caches, session stores, temporary build artifacts, short‑lived secrets.
D) Device mounts (pass block devices into containers)
For specialized workloads, you can pass raw block devices or other hardware into containers. This is common for monitoring agents, GPU/accelerator access, or databases that want to manage disks directly. Treat this as an advanced pattern: you’re granting powerful access to the host, so apply least‑privilege and document why it’s necessary.
Read‑only device example:
|
1 2 3 4 |
docker run --rm -it \ --device /dev/sdb:/dev/xvdb:r \ alpine fdisk -l /dev/xvdb |
Pros: Direct hardware access; unlocks high‑performance or specialized features.
Cons: Bigger blast radius; portability and scheduling constraints; requires host admin.
When to use: Hardware‑backed volumes, observability/backup agents, specialized databases.
E) Volume drivers & remote storage
When your app spans multiple hosts or you want centralized data management, volume drivers connect Docker to network storage (NFS, SMB/CIFS, AWS EFS) or vendor platforms (NetApp, Ceph, Portworx, Longhorn, etc.). This unlocks shared access, snapshots, and replication—but introduces network latency and backend semantics you must account for.
Example (NFS via local driver options):
|
1 2 3 4 5 6 7 |
docker volume create \ --driver local \ --opt type=nfs \ --opt o=addr=10.0.0.12,nolock,soft,rw \ --opt device=:/export/appdata \ nfs_appdata |
Pros: Centralized storage, multi‑host access, snapshot/replication (backend‑dependent).
Cons: Network variability; consistency models differ; operational overhead.
When to use: Swarm/multi‑host Docker, shared content, centralized backups, HA designs.
How to determine what storage a container is using
Sometimes you inherit a running container and need to answer: where does this data actually live? Use the steps below.
1) Identify the host storage driver (layers)
This tells you how the image and writable layers are managed on the host.
|
1 2 |
docker info --format '{{.Driver}}' # e.g., overlay2 |
You can also check a specific container:
|
1 2 |
docker inspect -f '{{.GraphDriver.Name}}' <container> |
2) List a container’s mounts (type, target, and source)
|
1 2 3 |
docker inspect -f '{{range .Mounts}}{{.Type}} {{.Destination}} {{.Source}}{{printf " "}}{{end}}' <container> |
Typical results:
volume→ Docker‑managed named volume;Sourceshows the volume name.bind→ Host path bind mount;Sourceis the host path.tmpfs→ In‑RAM mount; no on‑disk source.
3) If it’s a volume, inspect details
|
1 2 3 |
# replace VOLUMENAME with the value from .Mounts[].Source when Type=volume docker volume inspect VOLUMENAME | jq '.[0] | {Name, Driver, Mountpoint, Options}' |
This reveals the backing driver (e.g., local, NFS options) and the host mountpoint used by Docker.
4) If it’s a bind mount, validate the host path
- Check ownership/permissions and SELinux/AppArmor labeling if applicable.
- Verify whether the path is ephemeral (tmpfs on host) or durable (disk).
5) If it’s tmpfs, confirm tmpfs options
|
1 2 |
docker inspect -f '{{json .HostConfig.Tmpfs}}' <container> |
Look for size, noexec, nosuid, etc. Remember: tmpfs is volatile and lives in RAM.
6) Check device passthrough (advanced)
|
1 2 |
docker inspect -f '{{json .HostConfig.Devices}}' <container> |
A non‑empty list indicates raw devices are exposed to the container.
7) See space usage & writable layer size
|
1 2 3 |
docker system df -v # image, container, and volume usage breakdown docker ps --size # includes container RW layer size |
8) From inside the container (sanity check)
|
1 2 3 |
# show mounts and identify tmpfs/overlay tail -n +1 /proc/mounts | cut -d' ' -f1-4 | column -t | grep -E 'overlay|tmpfs|/var/lib/docker' |
This helps correlate container paths with host storage.
9) With Docker Compose
- Find the container name:
docker compose ps. - Inspect mounts as above.
- To see declared volumes/mounts from code, run
docker compose configand review the resolvedvolumes:entries (named volumes vs host paths).
Quick heuristic: If
.Mounts[].Typeisvolume, treat it as persistent and back it up. Ifbind, persistence depends entirely on the host path you mounted. Iftmpfs, it’s ephemeral by design.
Docker Compose examples
Persistent app + database
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
services: db: image: postgres:16 environment: POSTGRES_PASSWORD: example volumes: - pgdata:/var/lib/postgresql/data app: build: . volumes: - app_uploads:/usr/src/app/uploads ports: - "8080:8080" volumes: pgdata: app_uploads: |
Dev workflow with bind mount + tmpfs cache
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
services: web: image: node:20 working_dir: /app volumes: - ./:/app - type: tmpfs target: /app/.cache tmpfs: size: 268435456 # 256 MiB command: ["npm", "run", "dev"] ports: - "3000:3000" |
Choosing the right option (decision guide)
- Do you need the data after the container is gone?
- Yes → Use a volume (named). Consider a driver if you need remote or multi‑host storage.
- No → Use the container’s writable layer or tmpfs for speed/safety.
- Is this local development?
- Yes → Prefer bind mounts for live editing.
- No → Prefer volumes for portability and ops.
- Do multiple containers/hosts need to share data?
- Yes → Use a volume backed by network storage / volume plugin. Avoid bind mounts across hosts.
- Is the data sensitive and short‑lived?
- Yes → tmpfs (and consider
noexec,nosuid).
- Yes → tmpfs (and consider
Performance considerations
Performance is a mix of filesystem behavior, copy‑on‑write overhead, and where your bytes travel. Writing large files into the container’s writable layer can trigger extra CoW work; writing to a volume avoids some of that overhead. On laptops, bind mounts can feel slow because every file op crosses the host↔VM boundary; volumes or in‑container builds can smooth that out. For databases, the bottleneck is usually the underlying device (NVMe vs. networked storage), so benchmark with real workloads and tune flush/fsync behavior carefully.
Key takeaways:
- Prefer volumes for heavy write paths.
- On macOS/Windows, mitigate bind‑mount slowness by building inside containers or using volumes for large dependency trees.
- Choose a sensible base:
ext4+overlay2works well for general use; explore ZFS/Btrfs when you need snapshots/quotas.
Security & permissions
Storage is also a security boundary. Mount only what you need, and mount it with the narrowest rights possible. Avoid --privileged; prefer targeted device or capability grants. If you run on SELinux, use :z or :Z with bind mounts so labels are correct; AppArmor profiles may require path allowances. Align container user IDs with host ownership to avoid mysterious EACCES errors, and consider rootless Docker where feasible.
Examples:
|
1 2 3 4 5 6 |
# Read‑only mounts for config -v config:/etc/myapp:ro # SELinux‑aware shared content -v /srv/appdata:/app/data:Z |
For secrets, don’t bake them into images. Use Docker Swarm secrets, tmpfs, or an external secret manager (Vault, AWS SSM), and mount them at runtime with least privilege.
Backups, migration, and disaster recovery
If you can’t restore it, you don’t really have it. Treat volumes like any other production datastore: schedule backups, test restores, and version schemas so rollbacks are deterministic. For small installations, a simple tar of the volume can suffice. Larger environments might lean on filesystem snapshots (ZFS/Btrfs) or storage‑native replication.
Straightforward backup of a named volume:
|
1 2 3 4 5 6 |
# stop or quiesce the app as needed, then: docker run --rm \ -v appdata:/data \ -v $(pwd):/backup \ alpine sh -c "cd /data && tar czf /backup/appdata-$(date +%F).tgz ." |
Document who runs restores, where artifacts live, and how to validate data integrity after recovery.
Anti‑patterns to avoid
- Storing critical data only in the container’s writable layer.
- Using bind mounts for production databases.
- Relying on implicit host paths without configuration management.
- Ignoring file ownership/permissions until deploy day.
- Skipping backups because “containers are immutable”. Data isn’t.
Quick reference comparison
Sometimes you just need a quick nudge. Use this table to sanity‑check your choice, then follow the detailed guidance above to account for your environment’s quirks and compliance needs.
| Feature/Need | Volumes | Bind Mounts | tmpfs | Container Writable |
|---|---|---|---|---|
| Persistence | ✔️ | ✔️ (host‑dependent) | ❌ | ❌ |
| Portability | ✔️ | ❌ | ❌ | ❌ |
| Performance | ✔️ (generally) | Varies by OS/virtualization | ⚡ RAM | OK, CoW overhead |
| Security | Strong (with controls) | Host‑coupled risks | In‑RAM (ephemeral) | Inside container |
| Best for | DBs, uploads, state | Dev, configs, one‑offs | Cache, temp, secrets | Disposable scratch |
FAQ
Q: Should I run databases in containers?
A: Yes—provided you handle storage and backups intentionally. Use named volumes (or a networked driver), set conservative fsync/WAL settings for your backend, and ensure you can restore quickly in staging.
Q: How do I move data between hosts?
A: Snapshot or tar the volume, transfer it, and restore. For frequent moves or HA, use a network volume plugin so the data already lives off‑host.
Q: Do I need Kubernetes for persistent storage?
A: No. Docker volumes are perfectly fine. If you later move to Kubernetes, the concepts map to Persistent Volumes and StorageClasses.
Final recommendations
- Default to named volumes for anything persistent.
- Use bind mounts for local dev and targeted config files.
- Leverage tmpfs for ephemeral and sensitive runtime data.
- Know your storage driver and filesystem characteristics.
- Back up volumes and test restores.




