open atlas
↑ Back to track
Deployment & Infra DEP · 09 · 02

Volumes and persistence

A container's writable layer dies with the container, so persistent data needs a mount: a Docker-managed named volume for databases, a bind mount for dev source, tmpfs for in-memory secrets — each with its own lifecycle and traps.

DEP Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A docker compose down && docker compose up on the staging box wiped the database. Two weeks of seeded test data, gone — no rm, no DROP, no human error in the usual sense. Postgres had been writing to the container’s own writable layer the whole time, because nobody declared a volume. Removing the container removed the layer, and the layer was the data. The fix was one line in the compose file. The lesson was that container storage is ephemeral by default, and you opt into persistence on purpose.

The writable layer is scratch space, not storage

Before you add a database to any Docker setup, ask yourself: where does a restart leave your data? The answer determines whether you come in on Monday to find an empty database.

A running container is its read-only image layers plus one thin writable layer on top, stitched together by a union filesystem. Every file your process creates or modifies at runtime — a Postgres data directory, an uploaded avatar, a log file — lands in that writable layer. It feels like a normal disk, which is exactly the trap.

That layer is bound to the container’s lifecycle. Remove the container (docker rm, compose down, a redeploy that recreates it) and the writable layer is deleted with it. The data is not “somewhere on the host you can dig out” — it lived inside the container’s own diff and is gone. Worse, while the container is alive, everything you write there inflates the container’s size and goes through the storage driver’s union-filesystem abstraction, which is slower than writing to the host directly. The writable layer is for ephemeral scratch — temp files, a process’s runtime state — never for anything you’d be upset to lose.

To persist data you mount storage that lives outside the writable layer. Docker gives you three mount types, and choosing the right one is the whole skill.

Named volumes: the default for data you keep

A named volume is storage Docker creates and manages for you, living on the host under /var/lib/docker/volumes/<name>/_data. You never touch that path directly — you refer to the volume by name, and Docker handles the rest. This is the recommended mechanism for anything stateful: database files, message-queue data, uploaded user content.

# Create on demand and mount it; -v <name>:<container-path>
docker run -d --name db \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16

# The explicit, self-documenting form
docker run -d --name db \
  --mount type=volume,src=pgdata,dst=/var/lib/postgresql/data \
  postgres:16
# docker-compose.yml — the one line that saves the staging box
services:
  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

Named volumes win for stateful data on three counts. They are decoupled from host layout — you reference pgdata, not /home/deploy/whatever, so the same compose file runs on your laptop and the server. They are portable and backup-able — Docker can manage, inspect, and back them up as first-class objects, and they survive container removal by design. And they write to the host filesystem directly rather than through the union layer, so they are faster for the write-heavy workloads databases generate.

Bind mounts: a host path mapped straight in

A bind mount maps a specific directory or file from the host machine into the container. Unlike a volume, you own the host path and its layout; Docker just wires it through, and the host and container see the same files live.

# Mount the current project source into the container for live reload
docker run -it \
  --mount type=bind,src="$(pwd)",dst=/app \
  node:22 bash

# Short form (-v with an absolute host path is a bind mount, not a volume)
docker run -it -v "$(pwd)":/app node:22 bash

The killer use case is development: bind-mount your source tree into the container and your dev server hot-reloads on every save, no rebuild. But bind mounts couple you to the host’s directory layout (the path must exist and look the same everywhere the container runs), and they carry a sharp edge — a bind mount over a path the image already populated hides that path’s original contents for the life of the mount. Bind an empty host folder onto /app after node_modules was installed there at build time, and the container now sees an empty /app — the install is shadowed, not merged. That is one of the most common “it worked in the image but not when I run it” mysteries.

Why this works

Why does a bind mount shadow rather than merge? The mount replaces what the container sees at that path with the host’s view of it, exactly like mounting a USB stick over a folder on your laptop: the folder’s original contents are still on disk underneath but invisible while the mount is active. Named volumes behave differently in one helpful way — when you mount an empty named volume onto a path that the image populated, Docker copies the image’s existing files into the fresh volume first, so the data is seeded rather than hidden. A bind mount never does this copy-up.

tmpfs: in-memory, never persisted

A tmpfs mount lives only in the host’s RAM. Nothing is ever written to disk, and the moment the container stops the data evaporates. That is the point: it is for data you specifically do not want to persist — decrypted secrets, session scratch, intermediate caches you would rather not leave on a disk.

# Mount an in-memory filesystem at /run/secrets (Linux only)
docker run -it --tmpfs /run/secrets myapp

# Explicit form with options
docker run -it \
  --mount type=tmpfs,dst=/run/secrets,tmpfs-size=64m \
  myapp

Reach for tmpfs when a secret must exist as a file for one process to read but should never touch durable storage, or for hot scratch space where RAM speed matters and durability does not. Never use it for anything you need after a restart — by design, there is nothing to recover.

PropertyNamed volumeBind mounttmpfs
Managed byDocker (under /var/lib/docker/volumes)You (any host path)Kernel (host RAM)
Persists?Yes — survives container removalYes — lives on the hostNo — gone on stop
Use caseDatabase / stateful dataDev source live-reloadSecrets / in-memory scratch
PortabilityHigh — name, not a pathLow — couples to host layoutN/A — nothing stored

Volume lifecycle: the “where did my disk go” trap

Volumes have a lifecycle that is deliberately independent of containers, and that independence cuts both ways. A volume is created on demand the first time you reference it, and it survives container removal — that is the durability you want. But it also means a volume is never cleaned up automatically. Delete the container and the named volume stays, occupying disk, until you explicitly remove it.

docker volume ls                 # list volumes
docker volume inspect pgdata     # see the Mountpoint on the host
docker volume rm pgdata          # remove one volume (must be unused)
docker volume prune              # remove ALL volumes not used by a container

The senior trap lives in docker volume prune. It deletes every volume not currently attached to a container — and a volume whose container you removed last week is, by that definition, “unused.” Run a careless prune to reclaim disk and you can erase a database you fully intended to keep. The flip side, stale volumes silently eating disk, is the more common day-to-day surprise: months of dead anonymous volumes from removed containers pile up under /var/lib/docker/volumes until the host runs out of space.

Pick the best fit

A Postgres container must keep its data across redeploys that recreate the container. Where should the data directory live?

The senior cases: host-local is not the network

Three rules separate someone who can mount a volume from someone who can run stateful workloads. First, database data MUST live on a named volume, not the container layer — the hook is this rule learned the hard way. Second, never bind-mount over a path the image populated unless you specifically mean to shadow it; that is how node_modules and other build-time content silently vanish at runtime. Third, and the one that bites at scale: volumes are host-local. A named volume lives on one machine’s disk. In a multi-node cluster (Swarm, Kubernetes, several VMs behind a load balancer), a volume on node A is invisible to a container scheduled onto node B. For multi-node persistence you need networked or object storage — a managed database, S3-compatible object storage, or a CSI/network-volume driver — not a plain local volume. Treating a local volume as if it were shared storage is the classic mistake when a single-host compose setup graduates to a cluster. Together these three rules mean that storage decisions made at compose-file time determine your resilience at cluster scale — skipping the second rule loses data silently, skipping the third loses an entire node’s data at scale.

Quiz

A Postgres container with no volume declared is removed and recreated during a redeploy. What happens to the data?

Quiz

Your image installs node_modules at /app during build. You then bind-mount an empty host folder onto /app at runtime. What does the container see?

Recall before you leave
  1. 01
    Why is data written inside a container ephemeral by default, and what are the three mount types you use to persist (or deliberately not persist) it?
  2. 02
    Why is `docker volume prune` dangerous, and why are local volumes not enough for a multi-node deployment?
Recap

A running container is its read-only image layers plus one thin writable layer, and that writable layer is bound to the container’s lifecycle — remove the container and everything written there is deleted with it, which is why a volume-less Postgres loses its data on the next redeploy. Persistence is opt-in, through one of three mounts attached from outside the writable layer. A named volume is Docker-managed storage under /var/lib/docker/volumes, referenced by name; it is decoupled from host paths, portable, backup-able, writes to the host filesystem directly, and survives container removal, which makes it the default for databases and any stateful data. A bind mount maps a specific host path straight in — perfect for dev live-reload, but it couples to host layout and silently shadows whatever the image already populated at that path, so never bind-mount over a directory like a build-time node_modules unless you mean to hide it. A tmpfs mount lives only in RAM and vanishes on stop, for secrets and scratch you never want on disk. Volumes have an independent lifecycle: created on demand, surviving container removal, and removed only explicitly — which is why docker volume prune can wipe a database you meant to keep, and why dead volumes quietly eat disk. Finally, volumes are host-local: in a multi-node cluster you need networked or object storage, not a plain local volume. Now when you open a compose file, check whether every stateful container has a named volume declared — that one line is the difference between a staging wipe and a Monday morning you can recover from.

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
Connected lessons

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
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.