Keeping Data With Volumes
Containers forget everything when they stop. Bind mounts, named volumes and tmpfs — which one to reach for, and the mistake that silently loses a database.
Containers forget everything when they stop. Bind mounts, named volumes and tmpfs — which one to reach for, and the mistake that silently loses a database.
Run a database in a container, add some rows, stop the container, start a new one — and the rows are gone. Nothing malfunctioned. Containers are designed to be disposable, and everything written inside one is disposed of with it.
That is fine for a stateless web server and catastrophic for anything that remembers. By the end of this lesson you will know the three ways to attach storage that outlives a container, which one to reach for in which situation, and the single mistake that deletes a development database without asking.
You met the writable layer earlier: when a container starts, Docker adds a thin read-write layer on top of the image's read-only ones, and every file the container creates lands there.
The important part is its lifetime. That layer belongs to that one container. Stop the container and the layer survives; remove the container and the layer is destroyed with it. There is no intermediate state and no undo.
docker run -d --name db -e POSTGRES_PASSWORD=devpass postgres:16
docker exec -it db psql -U postgres -c "CREATE TABLE t (id int);"
docker rm -f db # the table went with itDocker gives you three kinds of mount, meaning a path inside the container that is not part of the image or its writable layer.
A volume is storage Docker creates and manages, in an area on the host it owns. You refer to it by name and never care where it physically lives. This is the default choice for data.
A bind mount maps a directory on your machine to a path in the container. You choose the host path exactly. This is the choice for source code during development.
A tmpfs mount exists only in the host's memory and vanishes when the container stops. This is for data you actively want to lose — scratch space, secrets you do not want written to disk.
| Volume | Bind mount | tmpfs | |
|---|---|---|---|
| Managed by | Docker | You | Docker |
| Lives | Docker's area | A path you pick | Memory only |
Survives rm | Yes | Yes | No |
| Portable | Yes | No, host-specific | Yes |
| Use for | Databases, uploads | Source code in dev | Scratch, secrets |
Create one implicitly by naming it in docker run. Docker makes it
on first use:
-v pgdata:/var/lib/postgresql/data reads as
volume-name:path-inside-container. Postgres stores its files at
that path, so its files now land in the volume rather than in the
container.
Prove it survives:
A brand new container, and the data is waiting for it. That is the whole feature.
Volumes are first-class objects with their own commands:
A bind mount points at a directory you choose:
Now /app inside the container is your project directory. Edit
app.py in your editor and the container sees the change
instantly, because there is only one copy of the file.
This is how you get a normal edit-and-refresh loop inside a container. Pair it with a development server that reloads on change and you never rebuild while working:
Two things to know. The host path must be absolute, which is why
$(pwd) appears rather than .. And a bind mount covers what
was at that path in the image — if the image had files at /app,
they are hidden while the mount is in place, not merged with it.
That covering behaviour causes one specific, confusing failure:
Bad — the dependencies installed during the build disappear at run time:
Good — keeps the image's
node_modules from being covered by the host's:
Nothing about that error mentions mounts, which is why it takes so long to diagnose the first time.
The build put node_modules in the image
npm ci ran during the build and wrote to /app/node_modules
inside the image. It is genuinely there.
The bind mount covers /app entirely
A mount hides what the image had at that path — it does not
merge with it. node_modules is still in the image and no
longer visible.
Your host copy is absent, or wrong
Either you never ran npm install locally, or you did and it
was built for a different platform than the container.
-v /app/node_modules puts the image's back
A bare path with no source creates an anonymous volume over that one subdirectory, so the image's own modules show through the hole.
There is a second, longer syntax that says the same things explicitly:
Both forms work and neither is deprecated. -v is shorter and what
you will mostly see; --mount is clearer in a file somebody has to
maintain, and it has one genuinely important difference:
If you give -v a host path that does not exist, Docker creates it
as an empty directory owned by root — so a typo in a path silently
produces an empty mount and a confusing error inside the container.
--mount fails loudly instead. When a bind mount is mysteriously
empty, a mistyped path is the first thing to check.
/tmp is now memory, capped at 64 MB, and gone when the container
stops. Two honest uses: scratch space that would otherwise churn
through the copy-on-write layer, and short-lived secrets you would
rather never touch a disk.
A volume is data you care about, so you need a way to get a copy out. The idiom is a throwaway container with both the volume and a host directory mounted:
And to restore into a fresh volume:
For a database, prefer its own dump tool — pg_dump, mysqldump —
which produces a consistent snapshot of a running database. Copying
files underneath a live database can capture it mid-write.
Volumes are deliberately hard to lose, which makes the two commands that do delete them worth memorising as a small, separate list.
Safe to run any time.
docker rm db removes the container and nothing else.
docker system prune explicitly does not touch volumes.
docker compose down stops everything and keeps the data.
Depends what is running.
docker volume prune removes every volume no container
currently references — which includes yesterday's database, if
you removed yesterday's container.
No confirmation, no undo.
docker volume rm pgdata for one.
docker compose down -v for every volume in the project.
Your data can now outlive its container. The remaining gap is
conversation: your application needs to reach that database, and
localhost inside a container does not mean what you expect. That
is the next lesson.
Then configuration, then Compose — which is where volumes stop being
long -v flags you retype and become three lines in a file. Before
moving on, run a Postgres container with a named volume, put
something in it, destroy the container, and bring the data back. The
first time you delete a container without losing anything, the model
stops feeling risky.
docker run -d --name db \
-e POSTGRES_PASSWORD=devpass \
-v pgdata:/var/lib/postgresql/data \
postgres:16docker exec -it db psql -U postgres -c "CREATE TABLE t (id int);"
docker rm -f db # destroy the container
docker run -d --name db \
-e POSTGRES_PASSWORD=devpass \
-v pgdata:/var/lib/postgresql/data \
postgres:16
docker exec -it db psql -U postgres -c "\dt" # the table is theredocker volume ls # every volume on this machine
docker volume create pgdata # create one explicitly
docker volume inspect pgdata # where it lives, when it was made
docker volume rm pgdata # delete it and its contentsdocker run -d -p 8000:8000 \
-v "$(pwd)":/app \
hello-dockerdocker run --rm -p 8000:8000 \
-v "$(pwd)":/app \
-e FLASK_DEBUG=1 \
hello-docker flask --app app run --host 0.0.0.0 --port 8000docker run -v "$(pwd)":/app my-node-app
# Error: Cannot find module 'express'docker run -v "$(pwd)":/app -v /app/node_modules my-node-appdocker run -d \
--mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
--mount type=bind,source="$(pwd)"/config,target=/config,readonly \
postgres:16docker run -d --tmpfs /tmp:size=64m nginxdocker run --rm \
-v pgdata:/source:ro \
-v "$(pwd)":/backup \
ubuntu tar czf /backup/pgdata.tar.gz -C /source .docker run --rm \
-v pgdata:/target \
-v "$(pwd)":/backup \
ubuntu tar xzf /backup/pgdata.tar.gz -C /target# Named volumes: Docker-managed storage for real data
docker volume create pgdata
docker volume ls
docker volume inspect pgdata
docker volume rm pgdata # deletes the data
docker run -v pgdata:/var/lib/postgresql/data postgres:16
# Bind mounts: a host directory, for source code in development
docker run -v "$(pwd)":/app hello-docker
docker run -v "$(pwd)"/config:/config:ro hello-docker # read-only
docker run -v "$(pwd)":/app -v /app/node_modules my-node-app
# tmpfs: memory only, gone on stop
docker run --tmpfs /tmp:size=64m nginx
# The explicit form, which fails loudly on a bad path
docker run --mount type=volume,source=pgdata,target=/data postgres:16
docker run --mount type=bind,source="$(pwd)",target=/app,readonly app
# Inspecting what a container has mounted
docker inspect --format '{{json .Mounts}}' db
# Backup and restore
docker run --rm -v pgdata:/source:ro -v "$(pwd)":/backup \
ubuntu tar czf /backup/pgdata.tar.gz -C /source .
# Cleaning up (the last two destroy data)
docker container prune # safe: stopped containers only
docker volume prune # volumes no container references
docker volume rm pgdata # one volume, immediately