DEV Community

Cover image for Docker Volumes vs Bind Mounts: Where Your Data Actually Lives
James Joyner
James Joyner

Posted on

Docker Volumes vs Bind Mounts: Where Your Data Actually Lives

A container's writable layer feels like a filesystem, and that's exactly the trap. Write a database into it, remove the container, and the data is gone — no warning, no recovery. If you want anything to survive docker rm, it has to live outside the container, and Docker gives you three ways to do that: named volumes, bind mounts, and tmpfs. Knowing which one to reach for is most of the battle.

Why the writable layer betrays you

Every running container gets a thin read-write layer stacked on top of its image layers. It looks persistent because you can docker exec in and see your files. But that layer is bound to the container's lifecycle.

docker run --name scratch alpine sh -c 'echo hello > /data.txt; cat /data.txt'
# hello
docker rm scratch
# the layer — and /data.txt — no longer exists
Enter fullscreen mode Exit fullscreen mode

There's no "oops." The writable layer is discarded with the container. Persistence is not a default you get; it's a decision you make. That decision is a volume, a bind mount, or tmpfs.

Named volumes: the default for state

A named volume is storage that Docker creates and manages for you. You give it a name, Docker keeps the actual bytes under its own directory, and you never have to care where that is.

docker volume create pgdata
docker run -d --name db \
  --mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
  postgres:16
Enter fullscreen mode Exit fullscreen mode

The container writes to /var/lib/postgresql/data, but those bytes land in a Docker-managed location on the host. Remove and recreate the container against the same volume and the data is still there.

docker rm -f db
docker run -d --name db \
  --mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
  postgres:16
# same data, new container
Enter fullscreen mode Exit fullscreen mode

Where do the bytes actually live? Under Docker's data root, typically /var/lib/docker/volumes/<name>/_data:

docker volume inspect pgdata --format '{{ .Mountpoint }}'
# /var/lib/docker/volumes/pgdata/_data
Enter fullscreen mode Exit fullscreen mode

The point is that you're not supposed to reach into that path directly — Docker owns it. You address the data by volume name, not host path, which is why volumes are portable across hosts and the right default for databases and app state.

Bind mounts: your host directory, mapped in

A bind mount points a container path straight at a directory you control on the host. No Docker management, no abstraction — it's your filesystem, exposed inside the container.

docker run -d --name web \
  --mount type=bind,source=/srv/appdata,target=/app \
  node:20 npm run dev
Enter fullscreen mode Exit fullscreen mode

This shines for local development. Mount your source tree in and edits on the host show up live inside the container, so a file-watcher reloads without a rebuild. It's also the natural way to feed in a config file.

The tradeoff is coupling. A bind mount hard-wires the container to your host's layout — /srv/appdata has to exist, with the right contents and permissions, on every machine that runs this. That portability cost is the whole reason volumes exist. My rule: bind mounts for dev convenience and config, named volumes for anything that's real state.

tmpfs: in-memory and gone on stop

Sometimes you want scratch space that never touches disk — a secret you don't want persisted, or a hot temp directory. A tmpfs mount lives in RAM and vanishes when the container stops.

docker run -d --name cache \
  --mount type=tmpfs,target=/tmp/scratch \
  alpine sleep 3600
Enter fullscreen mode Exit fullscreen mode

Nothing is written to the host filesystem. Use it for sensitive or throwaway data, not for anything you expect to find later.

-v shorthand vs --mount long form

You'll see two syntaxes. The old -v shorthand packs everything into one colon-separated string:

# named volume
docker run -v pgdata:/var/lib/postgresql/data postgres:16
# bind mount
docker run -v /srv/appdata:/app node:20
Enter fullscreen mode Exit fullscreen mode

The newer --mount form is explicit key=value:

docker run --mount type=volume,source=pgdata,target=/var/lib/postgresql/data postgres:16
docker run --mount type=bind,source=/srv/appdata,target=/app node:20
Enter fullscreen mode Exit fullscreen mode

They do the same thing, but -v has a sharp edge: with a bind mount, if the host path doesn't exist, -v silently creates it as an empty directory owned by root. --mount errors out instead. I recommend --mount for anything non-trivial — the verbosity buys you clarity and a loud failure when you get a path wrong.

The permissions gotcha everyone hits

This is the one that eats an afternoon. The container process runs as some UID, and files on a bind mount are owned by whatever UID owns them on the host. Those two numbers don't have to agree, and when they don't, you get denied.

Say the container runs as UID 1000 but /srv/appdata is owned by root (UID 0):

docker run --rm \
  --user 1000:1000 \
  --mount type=bind,source=/srv/appdata,target=/app \
  alpine sh -c 'echo test > /app/out.txt'
# sh: can't create /app/out.txt: Permission denied
Enter fullscreen mode Exit fullscreen mode

The container isn't confused — the kernel is enforcing ownership by number. Docker doesn't translate UIDs across the boundary. The fix is to make the numbers line up. Either match the host directory's ownership to the container's UID:

sudo chown -R 1000:1000 /srv/appdata
Enter fullscreen mode Exit fullscreen mode

Or run the container as the UID that already owns the files:

id -u        # say this prints 1000
docker run --rm --user "$(id -u):$(id -g)" \
  --mount type=bind,source=/srv/appdata,target=/app \
  alpine sh -c 'echo test > /app/out.txt && echo ok'
# ok
Enter fullscreen mode Exit fullscreen mode

The mental model that saves you: a UID inside the container is the same number as a UID on the host. There's no name mapping, only integers. Named volumes dodge much of this because Docker initializes their ownership from the image's expected user on first use — another reason they're the calmer default for stateful services.

Read-only mounts for config

If a mount only needs to be read — config files, certs, static assets — say so. Append :ro (or readonly) and the container can't modify it, which closes off a whole class of accidents.

docker run -d --name web \
  --mount type=bind,source=/srv/appdata/config.yaml,target=/app/config.yaml,readonly \
  node:20
Enter fullscreen mode Exit fullscreen mode

Read-only by default for config is a good habit. If the app tries to write where it shouldn't, you find out immediately instead of silently corrupting a shared file.

Inspecting and lifecycle

List and inspect volumes:

docker volume ls
docker volume inspect pgdata
Enter fullscreen mode Exit fullscreen mode

To see what a specific container has mounted, docker inspect its Mounts:

docker inspect db --format '{{ json .Mounts }}'
# [{"Type":"volume","Name":"pgdata","Source":"/var/lib/docker/volumes/pgdata/_data","Destination":"/var/lib/postgresql/data",...}]
Enter fullscreen mode Exit fullscreen mode

Now the danger. Every time you run a container that declares a volume without naming it, Docker creates an anonymous volume — a random-hash name you'll never recognize. These pile up quietly.

docker volume ls -qf dangling=true
# 8f3c...REDACTED
# a91d...REDACTED
Enter fullscreen mode Exit fullscreen mode

docker volume prune cleans up unused volumes, and that's genuinely useful for the anonymous cruft. But it does not discriminate between "junk I forgot about" and "the volume holding data I care about but isn't attached right now." A stopped-and-removed database whose named volume is momentarily unreferenced can be swept away. Read the prompt, and never wire prune into an unattended script.

Backing up a named volume

Because a named volume is just a directory Docker manages, you back it up by mounting it into a throwaway container alongside a backup target and tarring it up. No special tooling.

# back up pgdata to ./backup/pgdata.tar.gz
docker run --rm \
  --mount type=volume,source=pgdata,target=/data,readonly \
  --mount type=bind,source="$(pwd)/backup",target=/backup \
  alpine tar czf /backup/pgdata.tar.gz -C /data .
Enter fullscreen mode Exit fullscreen mode

Restore is the mirror image — mount the (empty) target volume and unpack into it:

docker run --rm \
  --mount type=volume,source=pgdata,target=/data \
  --mount type=bind,source="$(pwd)/backup",target=/backup \
  alpine sh -c 'cd /data && tar xzf /backup/pgdata.tar.gz'
Enter fullscreen mode Exit fullscreen mode

For a live database, quiesce or dump it first rather than tarring hot files — but as a pattern for volume data at rest, this is honest and dependency-free.

The same rules in Compose

Nothing changes conceptually in Compose; the syntax just moves into YAML. A top-level volumes: key declares named volumes, and a service can also bind-mount a host path — both resolve exactly as they do on the CLI.

services:
  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data          # named volume
      - /srv/appdata/config.yaml:/app/config.yaml:ro  # bind mount, read-only

volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode

If you want more end-to-end setups like this — services wired to the right kind of storage, with the Compose files laid out — there are worked Docker stack patterns to copy from.

Wrapping up

The one thing to carry with you: match the storage to the job. Named volumes for state you care about — databases, uploads, anything you'd cry over losing — because Docker manages them and they stay portable. Bind mounts for development and config, where being coupled to a host path is a feature, not a bug. tmpfs for secrets and scratch. And whatever holds your real data, prove you can restore it before you need to.

Top comments (0)