open atlas
↑ Back to track
Docker, containers as a system DOCK · 04 · 02

Volumes, bind mounts, and tmpfs: three ways to escape the writable layer

Named volumes are Docker-managed durable storage under /var/lib/docker/volumes; bind mounts paste a host path straight in; tmpfs lives in RAM. Each picks a different tradeoff in portability, UID semantics, and latency — and getting it wrong loses data or breaks writes.

DOCK Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The migration looked clean: move the Postgres container from a bind mount to a named volume for the production cutover. The team changed -v /data/pg:/var/lib/postgresql/data to -v pgdata:/var/lib/postgresql/data, redeployed, and watched Postgres initialize a brand-new empty database — the eight-week-old production data still sat in /data/pg on the host, now mounted nowhere, while the application happily served an empty schema and accepted new writes. Nobody noticed for forty minutes because the app didn’t crash; it just lost a customer’s entire account history. The deeper cut came a week later on a second host: the same named volume, but a bind mount of the source tree had been added for hot-reload, and the container ran as UID 1000 while the host files were owned by UID 0 — every write failed with permission denied and the framework swallowed the errors as “file not found.” Three storage mechanisms, three different failure shapes, one root cause: the team treated -v as one feature when it is three.

Named volumes: Docker owns the storage

A named volume is storage the engine creates and manages. docker volume create pgdata materializes a directory at /var/lib/docker/volumes/pgdata/_data, and mounting it at /var/lib/postgresql/data makes the container write straight to that host directory — bypassing the overlay2 storage driver entirely. That bypass is the whole performance story: writes go directly to the host filesystem instead of through the union mount, so there is no copy-up tax and no extra layer for the kernel to traverse. For a write-heavy workload like a database, a volume is not just for durability; it is materially faster than the writable layer, with none of the file-level copy-up stalls.

$ docker volume inspect pgdata --format '{{ .Mountpoint }}'
/var/lib/docker/volumes/pgdata/_data

# Volume survives the container; data outlives any rm/recreate:
$ docker run -d -v pgdata:/var/lib/postgresql/data postgres:16
$ docker rm -f $(docker ps -lq)      # container gone
$ docker run -d -v pgdata:/var/lib/postgresql/data postgres:16   # data still there

Volumes are portable and backup-friendly: because Docker owns the path and the lifecycle, you back them up, migrate them, and move them between Linux and Windows containers without caring about host directory layout. They also have a crucial first-run behavior: when you mount an empty named volume over a container directory that the image populated, Docker copies the image’s contents into the volume on first use — so mounting an empty pgdata over a fresh Postgres image preserves the image’s seed files. (This copy happens only for empty named volumes, never for bind mounts, and never if the volume already has data — which is exactly the trap in the Hook: a different empty volume initialized a blank database.)

Bind mounts: a raw host path, with the host’s rules

A bind mount maps a specific host path directly into the container: -v /data/pg:/var/lib/postgresql/data or the explicit --mount type=bind,source=/data/pg,target=.... There is no Docker-managed directory and no first-run copy — whatever is at the host path is what the container sees, immediately and bidirectionally. This is the right tool for development hot-reload (mount your source tree, edit on the host, the container sees it) and for injecting host config files. But you inherit the host’s filesystem semantics wholesale, and the sharp edge is UID/GID ownership.

Linux file permissions are numeric. The container has its own /etc/passwd, but the kernel enforces permissions by number, not name. If the host directory is owned by UID 0 and the container process runs as UID 1000 (as a hardened image should), every write gets EACCESpermission denied — regardless of what the username “looks like” inside the container. There is no automatic UID remapping for a plain bind mount (rootless Docker and user namespaces change this, but the default daemon does not), so the host directory must be chowned to the UID the container actually runs as, or the container must run as the UID that owns the host files. This is the single most common bind-mount failure in production, and frameworks routinely mask it as a missing-file error.

Quiz

A container running as UID 1000 bind-mounts a host directory owned by root (UID 0) and writes fail with permission denied. What is actually wrong?

tmpfs: storage that is RAM

Before you reach for a volume or a bind mount for something like session tokens or a sort-spill directory, ask yourself: do these bytes ever need to survive a restart? If not, tmpfs is the right tool. A tmpfs mount is neither persistent nor on disk — it is a filesystem in memory. --tmpfs /tmp or --mount type=tmpfs,destination=/tmp,tmpfs-size=64m gives the container a fast scratch area that never touches the storage driver or any disk, and vanishes when the container stops (it is even more ephemeral than the writable layer, which at least survives a stop/start). You use it for two things: secrets and sensitive data you never want written to disk, and high-churn scratch (session files, render temp, sort spill) where you want speed and zero durability. The catch is that tmpfs consumes the host’s RAM: without tmpfs-size, a tmpfs can grow until it exhausts memory and the OOM killer reaps the container, so production tmpfs mounts should always set an explicit size cap.

Quiz

You mount an empty named volume over /var/lib/postgresql/data on a fresh Postgres image, then on a second deploy you bind-mount an empty host directory at the same path. What happens to the image's seed files in each case?

Why this works

Why does an empty named volume copy the image contents but a bind mount does not? Because a named volume is a Docker-managed object with no prior identity — Docker can safely seed it from the image so the container starts in a sane state, which is what makes “just add a volume” work for stateful images. A bind mount points at a host path that already has meaning to the operator; silently overwriting it with image contents would be destructive and surprising. The asymmetry is a deliberate ownership boundary: Docker initializes what it owns and never touches what you own.

Recall before you leave
  1. 01
    Compare named volumes and bind mounts on lifecycle, performance, portability, first-run behavior, and the permission model.
  2. 02
    When should you reach for tmpfs instead of a volume or bind mount, and what is the operational risk?
Recap

The writable layer is the place state should never live, and Docker gives three ways out, each a different tradeoff. A named volume is storage the engine owns: it lives at /var/lib/docker/volumes/<name>/_data, survives any container removal or recreate, is portable and easy to back up because Docker abstracts away the host directory, and writes straight to the host filesystem so it skips overlay2’s copy-up entirely — for a database that means both durability and real speed. Its quiet superpower is the first-run copy: mount an empty named volume over a directory the image populated and Docker seeds the volume from the image, which is why a fresh volume on Postgres still holds an initialized cluster. A bind mount is the opposite philosophy: it pastes a specific host path straight into the container, bidirectionally, with no managed object and no first-run copy — ideal for development hot-reload and injecting host config, but you inherit the host’s filesystem rules whole. The recurring production wound is UID ownership: the kernel enforces permissions by number, there is no default remapping, so a container running as a non-root UID cannot write a root-owned host directory, and frameworks love to disguise the resulting EACCES as a missing file. tmpfs is the third option — a RAM-backed filesystem that never touches disk, ideal for secrets and high-churn scratch, gone the instant the container stops, and dangerous without a tmpfs-size cap because it eats host memory until the OOM killer fires. The senior reflex is to name the mechanism, not the flag: volumes for durable state, bind mounts for dev and host config with ownership checked, tmpfs for ephemeral or sensitive scratch with a size limit. Now when you see a -v flag in a Dockerfile or Compose file, your first question is not “will this persist?” but “which of the three mechanisms is this, and does the UID match?”

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.