CoursesDocker in depthVolumes, bind mounts & tmpfs

Volumes, bind mounts & tmpfs

The three ways to attach data.

Intermediate12 min · lesson 9 of 30

A container's writable layer is a whiteboard in a rented meeting room. You can scribble all over it, but when you hand the room back, someone wipes it clean. So anything you actually care about, the files behind a database, the source you're editing right now, a cache you'd rather not rebuild, has to live on a mount instead of inside the container. Docker gives you three kinds of mount. Choosing the right one is most of the skill here, and the difference comes down to lifecycle: how long the data lives, and who owns the files sitting on disk.

The three kinds

A volume is storage Docker creates and looks after for you, in its own corner of the host under /var/lib/docker/volumes. You don't pick where on disk it sits, and you mostly don't want to. It stays out of your way, and it keeps existing long after the container that used it is gone. A bind mount is the opposite arrangement. You name an exact directory on the host, and Docker maps it straight into the container. Whatever sits in that host directory is what the container sees, live, in both directions. A tmpfs mount (tmpfs is short for temporary filesystem) never touches disk at all. Treat it as a container-shaped RAM disk, where RAM means random-access memory, the fast kind that forgets everything the moment it loses power. It's quick. It vanishes when the container stops. All three do the same basic job, attaching storage at some path inside the container. Where the bytes land, and how long they stay, is where the three split apart.

Three mounts, three lifecycles
Volume
Docker manages it under /var/lib/docker/volumes
You don't pick the path
Outlives docker rm and survives redeploys
Persistent by default
Best for: databases, app state, backups
Bind mount
Maps an exact host directory into the container
You choose the path
Host and container share the files, live
Edits show up both ways
Best for: dev source, local config
tmpfs
Lives in RAM, never written to disk
Fast, volatile
Gone the moment the container stops
Nothing to recover
Best for: secrets, scratch space, caches

Prove it: data that outlives the container

Here's the situation that makes volumes click. You run Postgres, it writes its data files, and every deploy tears the container down and builds a fresh one. The data has to live through all of that. A named volume handles it cleanly, because deleting a container throws away its writable layer and leaves the volume untouched. Start Postgres on a new volume, give it a few seconds to initialize, then write a row. If you ever need to know where the volume physically lives, docker volume inspect pgdata will tell you, though the point of a volume is that you rarely have to ask.

terminal
$ docker volume create pgdata
pgdata
$ docker run -d --name pg \
--mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret postgres:16
9f2c1a4b7e03
$ docker exec pg psql -U postgres -c \
"CREATE TABLE note(msg text); INSERT INTO note VALUES ('i survived');"
CREATE TABLE
INSERT 0 1

Now delete that container outright and start a brand-new one pointed at the same volume. New container, same bytes underneath.

terminal
$ docker rm -f pg
pg
$ docker run -d --name pg \
--mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret postgres:16
3b7e5d90c1af
$ docker exec pg psql -U postgres -tAc "SELECT msg FROM note;"
i survived

The row is still there because the volume outlived the container. Volumes are also the tidy way to back data up. Mount the volume read-only next to a host directory inside a throwaway Alpine container, then tar the contents out. Restoring is the same trick with the two mounts swapped around.

terminal
$ docker run --rm \
-v pgdata:/data:ro \
-v "$(pwd)":/backup \
alpine tar czf /backup/pgdata-2026-07-16.tgz -C /data .
$ ls -lh pgdata-2026-07-16.tgz
-rw-r--r-- 1 root root 6.4M Jul 16 10:31 pgdata-2026-07-16.tgz

-v versus --mount

You'll meet two syntaxes for every mount you ever write. The old -v shorthand squeezes source, target and options into one colon-separated string. Short, yes. Also ambiguous, and it carries one genuinely nasty habit: hand it a bind source path that doesn't exist and it quietly creates an empty directory, then mounts that. The newer --mount spells each part out as key/value pairs, type, source and target. More typing, but it reads like a sentence, and it fails loudly when something is off. You can also mark any mount read-only by adding readonly to the --mount options, which is worth doing for config a container has no business writing back to. Watch what the two syntaxes do with a source that isn't there.

terminal
$ docker run --rm --mount type=bind,source=/data/prod,target=/app alpine ls /app
docker: Error response from daemon: invalid mount config for type "bind":
bind source path does not exist: /data/prod.
$ docker run --rm -v /data/prod:/app alpine ls -la /app
total 8
drwxr-xr-x 2 root root 4096 Jul 16 10:22 .
drwxr-xr-x 1 root root 4096 Jul 16 10:22 ..
$ ls -d /data/prod
/data/prod
One wrong character in a bind path, and you mount an empty folder
With -v, a bind source that doesn't exist gets created as an empty folder and mounted anyway, so a single typo hands your container a blank directory where your data should be. The app starts, behaves as if everything is missing, and prints nothing that explains why. --mount refuses a missing bind source outright, and that is exactly why it's the safer default for anything you'd hate to lose. In Compose, write the bind mount in long syntax with create_host_path: false so you get the same fail-fast behavior instead of the auto-create default.

tmpfs: fast, and gone on purpose

When a process needs scratch space, or a credential file that must never land on disk, tmpfs is the tool. It's memory-backed, so the path exists inside the container while the bytes sit in RAM. Nothing reaches the node's disk. Nothing turns up later in a backup. It's gone when the container stops. Always set tmpfs-size, because a tmpfs with no cap can grow until it eats the node's memory. Here's the vanishing act, right next to the volume you watched survive a moment ago.

terminal
$ docker run -d --name cache \
--mount type=tmpfs,target=/scratch,tmpfs-size=64m,tmpfs-mode=1777 alpine sleep 3600
7c4a9e21b0d5
$ docker exec cache sh -c 'echo hot-data > /scratch/x; mount | grep /scratch'
tmpfs on /scratch type tmpfs (rw,nosuid,nodev,relatime,size=65536k,mode=1777)
$ docker restart cache
cache
$ docker exec cache cat /scratch/x
cat: can't open '/scratch/x': No such file or directory

Bind mounts and the node_modules trap

Bind mounts earn their keep in development. Point one at your project directory and every edit you make on your laptop lands in the container straight away, no rebuild. Node projects hit one classic snag, though. Bind-mounting the whole project over /app also covers up the node_modules directory the image installed at build time, replacing it with whatever your host happens to have there, which is usually nothing, or a copy built for the wrong CPU architecture. The fix is to layer an anonymous volume over /app/node_modules alone. Docker seeds a fresh volume from the image's contents at that path, so the container keeps the dependencies it installed while your source stays live. One bit of housekeeping: anonymous volumes like this pile up every time you recreate the container, and docker volume prune clears out the ones nothing is using any more.

terminal
$ docker run -d --name dev \
--mount type=bind,source="$(pwd)",target=/app \
--mount type=volume,target=/app/node_modules \
myapp:dev
b1d3f74a9e26
$ docker exec dev ls /app/node_modules | head -3
express
pg
typescript
$ docker exec dev cat /app/src/index.ts | head -1
import express from 'express'

Match the mount to the data

Named volumes live in Docker's data root and outlive the containers that use them, which makes them the default choice for databases. Bind mounts map a host path, ideal for source code in development, risky in production if the path is wrong or world-writable. tmpfs keeps data in memory, fast and gone on stop, which suits sensitive scratch space. Mixing all three without labeling what actually gets backed up is how a team deletes a container and finds out too late that the Postgres directory was never on a volume.

Permissions and UID mapping bite people constantly. A UID (user ID, the number Linux uses to identify an account) that exists happily inside the image can land on the host as a different owner, and the process running in the container still has to be allowed to write to the mount. A read-only root filesystem needs explicit writable mounts for /tmp and any cache directory. Picking a bind mount over a volume trades portability across nodes for visibility on the host. On Swarm or any multi-host scheduler, prefer volumes backed by a driver that understands your storage, or you will eventually schedule a task onto a node where the bind path doesn't exist.

Sort the backup story out first. A volume with no snapshots sits one disk failure away from losing everything. Write down which volumes hold real state, which are disposable cache, and which jobs run the docker run --rm backup. During an incident, docker volume inspect and the mount propagation options explain a surprising share of "file not found" bugs that everyone assumed were application bugs.

Before you change a mount on anything holding real data, write down what it looked like first: the volume name, the target path inside the container, and what docker inspect reported under Mounts. That note is what lets you put things back when a redeploy comes up with an empty data directory at three in the morning. A boring, reversible step someone else can replay from the ticket beats a clever one-liner nobody can audit. If a teammate can't rebuild your mount layout from what you wrote down, the runbook isn't finished.

Try this

Run these on a lab engine (Docker 24 or newer is fine). Read the sample output first so you know what a healthy result looks like before you lean on the commands anywhere that matters.

terminal
$ docker volume create pgdata
$ docker run -d --name pg -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=secret postgres:16
$ docker inspect pg --format '{{json .Mounts}}'
[{"Type":"volume","Name":"pgdata","Destination":"/var/lib/postgresql/data","RW":true,…}]
$ docker rm -f pg
$ docker volume ls | grep pgdata
local pgdata
# volume still present after container delete — data survives

Takeaway

Named volumes for state you'll need tomorrow, bind mounts for host paths you chose deliberately, tmpfs for anything that should never outlive the process. Settle backups and node portability before you type docker rm, because by then the mount type has already decided what survives.

Quick check
01Your Node app runs fine from the image, but the moment you bind-mount your project over /app with --mount type=bind,source="$(pwd)",target=/app it dies saying it can't find its dependencies. What fixes it?
Correct — the bind mount covers the image's node_modules with whatever the host has there; a volume on that subpath gets seeded from the image and keeps the container's own copy.
Incorrect — both syntaxes mount the same way. The shorthand has no special handling for node_modules.
Incorrect — a tmpfs starts empty and lives in RAM, so your dependencies wouldn't be there at all and the whole directory would vanish on stop.
Incorrect — the image already has the dependencies. A bind mount is hiding them at runtime, so rebuilding changes nothing.
02Which of these describes a tmpfs mount correctly?
Correct — tmpfs sits in RAM, so nothing lands on disk or in a backup, and the path is empty again as soon as the container stops.
Incorrect — That describes a named volume, the persistent option. tmpfs is the opposite.
Incorrect — That's a bind mount. A tmpfs has no host directory behind it at all.
Incorrect — tmpfs-size only caps how big it can grow. The demo above shows the file gone after docker restart.
03You meant to bind-mount /data/prod, but you typed /data/prd (which doesn't exist) and ran docker run -v /data/prd:/app .... What does Docker do, and what would --mount have done instead?
Incorrect — That's the whole hazard of -v: it doesn't error, it creates the missing path for you.
Incorrect — --mount refuses a bind source that doesn't exist. Only -v creates it.
Incorrect — -v doesn't guess. It takes the typo literally and makes a new empty directory.
Correct — -v auto-creates the missing source as an empty folder, which is exactly why --mount is the safer default for real data.

Related