03-runtime-networking-storage

Volumes vs Bind Mounts: Making Data Outlive the Container

The writable layer dies with the container. Learn which storage option to reach for, the mount mistake that silently destroys a database, and how to verify it.

September 13, 2026
dockervolumesbind-mountstmpfspersistencepermissionsbackup

The Problem, Restated

From guide one of this roadmap: a container gets a thin writable layer on top of the image's read-only layers, and that writable layer is deleted when the container is removed.

For a stateless web server that's ideal — the container is disposable by design. For anything that remembers, it's a countdown timer. Every docker rm, every recreation to pick up a new image, every docker compose down takes the data with it.

Docker offers three ways to put data somewhere that survives.

The Three Options

VolumeBind mounttmpfs
Lives whereDocker-managed storage areaA path you choose on the hostHost memory
Survives container removalYesYesNo
Portable across machinesYesNo — depends on host pathsn/a
Best forDatabases, uploads, any real stateSource code during developmentSecrets, scratch data

Volumes — the default choice for state

bash
docker volume create pgdata
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:17

Docker manages where the data actually sits. The container just knows a named volume is mounted at a path. Volumes are the right answer for databases, message queues, uploaded files — anything that is genuinely state.

They also survive docker rm, which is the point, and they can be backed up, inspected and moved between containers.

Bind mounts — the default choice for development

bash
docker run -v "$(pwd)/src:/app/src" myapp

A specific host directory appears inside the container. Edit a file in your editor and the container sees the change immediately — which is what makes hot reload work in a containerised dev setup.

The tradeoff is portability: the mount depends on a path existing on this host with the right contents. That's fine for development and a liability in production.

tmpfs — for things that should never touch disk

bash
docker run --tmpfs /tmp:size=64m myapp

Backed by memory, wiped when the container stops. Appropriate for decrypted secrets, session scratch space, or anything you'd rather not leave recoverable on disk.

💡

The modern --mount syntax is more explicit than -v and worth preferring in anything you commit, because it fails loudly rather than guessing:

docker run --mount type=volume,source=pgdata,target=/var/lib/postgresql/data postgres:17

With -v, a typo in a host path silently creates a new empty directory. With --mount, it errors.

The Mistake That Destroys Databases

Here's the failure from this roadmap's scenarios, in detail — because it looks correct in review and fails silently.

yaml
services:
  db:
    image: postgres:17
    volumes:
      - pgdata:/data          # wrong path
volumes:
  pgdata:

The volume is declared. It's attached. docker inspect confirms a mount exists. Everything about this passes a glance.

But Postgres writes to /var/lib/postgresql/data, not /data. So the volume sits there holding nothing, while the database writes its entire state into the writable layer — which is deleted on the next docker compose down.

Nothing warns you. The container is healthy, queries work, and the data is one recreation away from gone.

🚨

A mounted volume proves nothing on its own. What matters is whether it's mounted at the path the application actually writes to. Check the image's documentation for its data directory, then verify — don't infer it from the volume's name.

The verification takes ten seconds:

bash
# What is actually mounted, and where?
docker inspect --format '{{json .Mounts}}' db | jq
 
# Is the application's data directory actually on the volume?
docker exec db df -h /var/lib/postgresql/data

If df reports the same filesystem as /, the data is in the writable layer and the volume is decorative.

Check yourself

A Redis container is configured with a named volume mounted at /data, and the team assumes state is safe. After a redeploy, all keys are gone. Redis was running with default settings and no persistence configured. What went wrong?

The Linux Permissions Problem

A bind mount issue that doesn't reproduce on macOS and therefore ambushes people in CI.

On Linux, bind mounts preserve the host's numeric user and group IDs. If your container runs as UID 1000 and the mounted directory is owned by UID 501, the container gets permission errors writing to it. On Docker Desktop, the file-sharing layer papers over this, so the same setup works on a laptop and fails on a Linux build agent.

Options, roughly in order of preference:

bash
# Run the container as the current host user
docker run -u "$(id -u):$(id -g)" -v "$(pwd):/app" myapp

Or align the container user's UID with the host's at build time, or — for state rather than source code — use a named volume, where Docker initialises ownership from the image and the problem doesn't arise.

⚠️

Mounting over a directory hides whatever the image had there. A classic case: mounting your project root over /app when the image installed node_modules into /app/node_modules — your host directory has no node_modules, so the container now sees none either. The usual fix is an anonymous volume for that subdirectory (-v /app/node_modules) so it isn't shadowed by the bind mount.

Anonymous Volumes Accumulate

If an image's Dockerfile has a VOLUME instruction, or you use -v /some/path with no source, Docker creates an anonymous volume — a real volume with a random hexadecimal name.

They're easy to create by accident and invisible until you look:

bash
docker volume ls                   # anonymous ones have long random names
docker system df -v                # how much space each is using
docker volume prune                # remove volumes not used by any container
🚨

Read docker volume prune carefully before running it. "Not used by any container" includes the volume holding your database from a container you removed an hour ago — the volume you were relying on to still be there. Check docker volume ls and confirm what's about to go.

Backing Up a Volume

Because a volume is Docker-managed, you back it up by mounting it into a throwaway container alongside a host directory:

bash
# Back up
docker run --rm \
  -v pgdata:/data:ro \
  -v "$(pwd):/backup" \
  alpine tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .
 
# Restore
docker run --rm \
  -v pgdata:/data \
  -v "$(pwd):/backup" \
  alpine sh -c "tar xzf /backup/pgdata-2026-09-13.tar.gz -C /data"

For databases specifically, a logical dump (pg_dump, mysqldump) taken from the running container is usually safer than a file-level copy, which can capture a half-written state unless the database is stopped or quiesced.

A backup you have never restored is a hypothesis, not a backup. Run the restore once, into a scratch volume, and confirm the data is actually there. This is the single step most teams skip and most regret.

Check yourself

For a production Postgres container, which storage choice is most appropriate and why?

What's Next

Data now survives. The final guide of this phase covers what happens when a container stops — why yours takes exactly ten seconds, what PID 1 has to do with it, and how to write a health check that means something.