Storage and Volumes

Difficulty

Volumes — Docker-managed, the recommended default

docker volume create my-data
docker run -d -v my-data:/app/data myapp:1.0

Docker creates and manages the actual storage location on the host, usually under /var/lib/docker/volumes/, fully abstracted away from you. You reference it by name, like my-data, not by a specific host path. Volumes are the recommended way to persist real application data — a database's files, or uploaded content — because Docker manages their lifecycle, backup tooling, and driver options consistently, no matter how the host's own directories are laid out.

Bind mounts — an arbitrary host path, mapped directly in

docker run -d -v /home/user/my-config:/app/config myapp:1.0
# or, using the more explicit --mount syntax:
docker run -d --mount type=bind,source=/home/user/my-config,target=/app/config myapp:1.0

Maps a specific, existing path on the host directly into the container. This gives full control over exactly where the data lives on the host. It's genuinely useful for specific cases: mounting your local source code into a container for live-reload development, or sharing an existing host directory. The tradeoff: the container now depends on that exact host path existing, with the right permissions and content. That ties it tightly to one host's directory layout, which hurts portability.

tmpfs mounts — memory-only, never touches disk

docker run -d --tmpfs /app/cache myapp:1.0

Data written here lives entirely in the host's RAM. It's extremely fast, but completely lost the moment the container stops — even a plain docker stop/docker start cycle loses it, unlike a volume or bind mount. Use it for genuinely temporary data: a cache you're fine losing, or sensitive temporary data (like a decrypted secret) you'd rather never touch disk at all.

Side-by-side comparison

VolumeBind mounttmpfs
Managed byDockerYou (an arbitrary host path)Docker (in-memory only)
Survives container removalYesYes (it's just a host directory)No — gone even on container stop
Portable across different hostsYes (referenced by name, not host path)No (tied to that host's specific path)N/A (never persists anywhere)
Typical useReal application/database dataLocal development (mounting source code), sharing a specific existing host resourceTemporary, sensitive, or performance-critical scratch data

Why volumes are generally preferred over bind mounts in production

A bind mount ties the container's correct behavior to one host's directory structure and permissions — exactly the kind of environment-dependent coupling containers are meant to eliminate. A volume, referenced purely by name, works identically no matter which host it runs on. That's a much better fit for production, where you want the same container configuration to behave the same way everywhere.

Related Resources

Portability across hosts and environments

# Bind mount: hardcodes a specific host path -- this exact path must exist,
# with correct permissions, on EVERY machine this container might ever run on
docker run -v /opt/myapp/data:/app/data myapp

# Named volume: portable -- Docker manages where it actually lives,
# and the SAME command works identically regardless of host layout
docker run -v app-data:/app/data myapp

A bind mount only works if /opt/myapp/data exists, with the right permissions, on whatever host this container runs on. That assumption breaks the moment you deploy to a different server, a different developer's laptop, or a fresh machine without that exact directory set up. A named volume has no such dependency — Docker creates and manages it consistently regardless of the host's own layout. This is the same portability guarantee containers are meant to provide for application code in the first place.

Consistent tooling and lifecycle management

docker volume ls
docker volume inspect app-data
docker volume prune            # clean up unused volumes

Docker's CLI and API have first-class commands for listing, inspecting, and cleaning up volumes. A bind mount is just an arbitrary host directory — Docker doesn't track it the same way. You have to figure out yourself which host directories are used by which containers, and clean them up with ordinary filesystem tools instead of Docker's own commands.

Volume drivers extend capability without changing application configuration

docker volume create --driver local --opt type=nfs --opt device=:/exported/path --opt o=addr=nfs-server.example.com my-nfs-volume

Named volumes support pluggable volume drivers. The same -v my-nfs-volume:/app/data reference in a container's config can be backed by local disk, NFS, a cloud storage service, or another backend entirely. You can swap the underlying storage without touching the container's configuration at all. A bind mount is always tied to whatever's literally at that host path — there's no equivalent abstraction to swap the backing storage.

Permission and ownership complications specific to bind mounts

Bind mounts frequently run into UID/GID mismatch issues. A container process running as a specific user ID needs permission to read and write the bound host directory, and host-side and container-side user ID mappings don't always line up — especially across different host operating systems, or when the container's internal user has no matching real user on the host. Named volumes, fully managed by Docker, avoid most of this, since Docker handles the storage directly rather than requiring alignment with an arbitrary directory's existing ownership.

When bind mounts are still the right, deliberate choice

  • Local development — live-mounting your source code into a container so changes show up immediately without rebuilding the image. A common, appropriate workflow.
  • Deliberately sharing a specific, known host resource — e.g., mounting /etc/localtime read-only to sync a container's timezone with the host's, or mounting a Unix socket like the Docker socket (mind the security caveats that come with that).

Reaching for a bind mount out of habit, rather than for one of these deliberate reasons, is usually a sign the default should have been a named volume instead.

Related Resources

Backing up a volume

docker run --rm \
  -v my-app-data:/source:ro \
  -v $(pwd):/backup \
  alpine \
  tar czf /backup/my-app-data-backup.tar.gz -C /source .

Breaking this down:

  • -v my-app-data:/source:ro — mounts the volume you want to back up, read-only via :ro, so the backup process can't accidentally modify the live data while reading it.
  • -v $(pwd):/backup — a bind mount, giving the temporary container access to your current host directory, so the archive ends up somewhere you can actually reach it afterward.
  • alpine — a minimal, throwaway image, chosen because it includes tar and little else needed for this one-off task.
  • tar czf /backup/my-app-data-backup.tar.gz -C /source . — compresses everything in /source (the mounted volume) into a single archive, written to /backup (the bind-mounted host directory).
  • --rm — removes this temporary container once the command finishes, since it has no purpose beyond this one backup.

Restoring from a backup

docker volume create my-app-data-restored

docker run --rm \
  -v my-app-data-restored:/target \
  -v $(pwd):/backup \
  alpine \
  tar xzf /backup/my-app-data-backup.tar.gz -C /target

The reverse process: create a fresh volume (or reuse an existing one, if restoring in place), mount it as the extraction target, and unpack the archive into it using the same kind of temporary, throwaway container.

Why this pattern works: volumes aren't tied to any specific "owning" container

The key idea: a named volume isn't permanently bound to whichever container originally used it. Any container can mount it, including a completely unrelated, temporary one whose only job is to run a backup/restore command. This same property is what makes volumes useful for migrating data between different application versions, or even different applications entirely — as long as both sides agree on the data format inside the volume.

Database-specific backup tools are usually still the better choice for real databases

docker exec my-postgres pg_dump -U postgres mydb > backup.sql

For an actual running database, native backup tools — pg_dump, mysqldump, and equivalents — are generally better than a raw filesystem-level tar of the volume. A live database's on-disk files can be mid-write and inconsistent if you archive them directly while it's running, unless you stop it first or the tool specifically supports safe hot-backup snapshotting. A proper dump tool guarantees a consistent, valid backup by using the database's own transactional guarantees, instead of copying raw files.

Automating this as a scheduled task

# A cron job, or a scheduled CI/CD pipeline step, running the backup command
# above on a regular schedule, pushing the resulting archive to durable,
# off-host storage (cloud object storage, a dedicated backup server) --
# never leaving backups only on the SAME host as the live data.

This follows the same backup principle as any stateful system: automate it on a regular schedule, store it somewhere genuinely separate from the live data so one host failure can't destroy both, and periodically test it by actually performing a restore. An untested backup isn't a real backup, no matter what command produced it.

The demonstration

docker run -d --name my-db postgres:16    # no volume mounted -- data lives ONLY in the writable layer
docker exec my-db psql -U postgres -c "CREATE TABLE important_data (...);"
# ... insert critical data ...

docker rm -f my-db
docker run -d --name my-db postgres:16     # a FRESH container, from the same image
docker exec my-db psql -U postgres -c "SELECT * FROM important_data;"
# ERROR: relation "important_data" does not exist

The second container is entirely new. It starts from the image's original, unmodified layers, with a fresh, empty writable layer. Every change made to the first container — the new table and its data — lived only in that container's now-deleted writable layer. That data is gone permanently, with no relationship to the second container, even though both started from the same image.

Why this is expected, correct behavior — not a bug

An image is an immutable, read-only template. Each container gets its own independent writable layer on top of it, via copy-on-write. That's what lets many containers start from the same image at once, each with fully independent state. But it also means a container's writable layer is tied to that one container's lifetime — not to the image, and not shared with any other container.

The fix: mount a volume for anything that needs to survive

docker volume create db-data
docker run -d --name my-db -v db-data:/var/lib/postgresql/data postgres:16

Now the database's actual data files live in the named volume db-data, not in the container's writable layer. Removing this container and starting a fresh one picks up exactly where the previous one left off, as long as it mounts the same volume:

docker rm -f my-db
docker run -d --name my-db -v db-data:/var/lib/postgresql/data postgres:16
docker exec my-db psql -U postgres -c "SELECT * FROM important_data;"
# the data is still there -- it was never IN the removed container's writable layer at all

The mental model this reinforces

Think of the writable layer as disposable, scratch space specific to one container instance. Volumes are the only place genuinely persistent data should live. Treat any file written outside a mounted volume path as something you're fine losing the instant that container is removed. Logs (which should go to stdout/stderr and be captured by Docker's logging driver), temporary caches, and other genuinely ephemeral data are fine in the writable layer. Real application data, database files, and uploaded content are not.

A common real-world mistake this explains

A common incident: a database or application was run without a mounted volume during initial setup, maybe for a "quick test" that quietly became the actual production deployment. Months of accumulated data are then permanently lost the first time that container is removed or replaced — a routine update, a host migration, or simple operator error. This happens because nothing was ever persisted outside that one container's writable layer. Checking that every stateful container mounts a proper volume for its data, instead of just assuming it does, is a basic production-readiness check.

Related Resources

The default: the local driver

docker volume create my-data
docker volume inspect my-data
# "Driver": "local"
# "Mountpoint": "/var/lib/docker/volumes/my-data/_data"

Without specifying a driver, Docker uses the built-in local driver, which just creates and manages a directory on the host's own disk. That's fine for single-host setups, but it means the volume's data is physically tied to that one host — if the container needs to move to a different machine, the volume and its data don't automatically come along.

Using an alternative volume driver

docker volume create --driver local \
  --opt type=nfs \
  --opt o=addr=192.168.1.100,rw \
  --opt device=:/exported/path \
  my-nfs-volume

docker run -d -v my-nfs-volume:/app/data myapp:1.0

This example uses the local driver's built-in NFS support, backing the "volume" with a remote NFS share instead of local disk. The container's own configuration, -v my-nfs-volume:/app/data, looks identical to using a plain local volume — only the volume's creation-time definition differs.

Third-party volume driver plugins extend this further, supporting cloud block storage, distributed storage systems like Ceph and GlusterFS, and other backends. Each implements Docker's volume plugin API, so from the container's perspective, using them needs no different syntax than any other named volume.

Why this abstraction matters

Container's perspective:  -v my-data:/app/data   (identical, regardless of backend)

Actual backend, depending on the driver used:
  - local disk (default "local" driver)
  - NFS share
  - Cloud block storage (via a cloud-specific driver)
  - A distributed storage system

This mirrors the same abstraction philosophy behind Kubernetes's StorageClasses and the Container Storage Interface (CSI). Application/container config references storage in an abstract, backend-agnostic way, while a pluggable driver layer handles the actual implementation underneath. The benefit is the same in both ecosystems: you can change or upgrade the underlying storage without rewriting every container's configuration.

When you'd actually reach for a non-default driver

  • Multi-host setups without a full orchestrator — if you're running plain Docker (not Swarm or Kubernetes) across multiple hosts, and need a container's data accessible regardless of which host it runs on, a network-backed volume driver like NFS or a distributed storage plugin solves this. The default local driver's host-tied storage can't.
  • Cloud-native storage integration — using a cloud provider's own volume driver plugin to back Docker volumes with that provider's managed block/file storage, gaining its durability, snapshotting, and replication features.

For simple, single-host Docker deployments, the default local driver is entirely sufficient and needs no extra configuration.

Related Resources