Volumes, bind mounts & tmpfs
The three ways to attach data.
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.
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.
$ docker volume create pgdatapgdata$ docker run -d --name pg \--mount type=volume,source=pgdata,target=/var/lib/postgresql/data \-e POSTGRES_PASSWORD=secret postgres:169f2c1a4b7e03$ docker exec pg psql -U postgres -c \"CREATE TABLE note(msg text); INSERT INTO note VALUES ('i survived');"CREATE TABLEINSERT 0 1
Now delete that container outright and start a brand-new one pointed at the same volume. New container, same bytes underneath.
$ docker rm -f pgpg$ docker run -d --name pg \--mount type=volume,source=pgdata,target=/var/lib/postgresql/data \-e POSTGRES_PASSWORD=secret postgres:163b7e5d90c1af$ 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.
$ 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.
$ docker run --rm --mount type=bind,source=/data/prod,target=/app alpine ls /appdocker: 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 /apptotal 8drwxr-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
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.
$ docker run -d --name cache \--mount type=tmpfs,target=/scratch,tmpfs-size=64m,tmpfs-mode=1777 alpine sleep 36007c4a9e21b0d5$ 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 cachecache$ docker exec cache cat /scratch/xcat: 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.
$ docker run -d --name dev \--mount type=bind,source="$(pwd)",target=/app \--mount type=volume,target=/app/node_modules \myapp:devb1d3f74a9e26$ docker exec dev ls /app/node_modules | head -3expresspgtypescript$ docker exec dev cat /app/src/index.ts | head -1import 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.
$ 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 pgdatalocal 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.
docker run -v /data/prd:/app .... What does Docker do, and what would --mount have done instead?