Here is a fact that surprises people the first time it bites them: when you remove a container, everything it wrote is gone. A container's filesystem is a temporary layer that is thrown away with the container. Restart your database container after an upgrade and, if you did nothing special, your data went with it.
The fix is to keep data outside the container. Docker gives you two ways to do that, volumes and bind mounts, plus a private network so your containers can find each other. This post covers all three, with real commands and output captured on a live Docker Engine.
Tip
Key takeaways
- A container's own filesystem is ephemeral. Anything you want to keep must live in a volume or a bind mount.
- Named volumes are managed by Docker and are the right default for databases and app data. They survive
docker rm.- Bind mounts map a specific host folder into the container, ideal for serving or editing live files during development.
- Back up a volume by running a throwaway container that tars its contents. Restore is the same trick in reverse.
- On a user-defined network, containers reach each other by name through Docker's built-in DNS. The default bridge does not do this.
Info
Get the code. The compose file, scripts, and net-demo are in the docker-foundations repo, under
06-volumes-networks/. Clone it to follow along.
Prerequisites
- Docker installed and running. See Install Docker on macOS, Windows (WSL2), and Linux.
- The basics of starting and removing containers from Run Your First Containers.
Named volumes: data that survives
A named volume is storage that Docker manages for you, separate from any container. You attach it with -v <name>:<path-in-container>. Let us prove it survives a container being destroyed and recreated.
Create a volume and start Postgres on it:
docker volume create demo_pgdata
docker run -d --name pg \
-e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
-v demo_pgdata:/var/lib/postgresql/data postgres:16-alpine
Give Postgres a few seconds to initialize, then write a row:
docker exec pg psql -U demo -d demo \
-c "CREATE TABLE notes(id serial primary key, body text);
INSERT INTO notes(body) VALUES ('survives a container rebuild');"
CREATE TABLE
INSERT 0 1
Now destroy the container completely, then start a brand new one on the same volume and read the row back:
docker rm -f pg
docker run -d --name pg2 \
-e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
-v demo_pgdata:/var/lib/postgresql/data postgres:16-alpine
docker exec pg2 psql -U demo -d demo -c "SELECT * FROM notes;"
id | body
----+------------------------------
1 | survives a container rebuild
(1 row)
The container was deleted and a new one created, and the data was still there. That is the whole point of a named volume. This is also why, in the Compose post, Postgres used a pgdata volume: without it, tearing the stack down and bringing it back up would start from an empty database.
Bind mounts: live files from the host
A bind mount maps a specific folder on your machine into the container. Changes on the host show up instantly inside the container, which is perfect for development. The syntax is the same -v, but the left side is a host path instead of a volume name.
Serve a folder with nginx:
echo "<h1>version one</h1>" > ./site/index.html
docker run -d --name web -p 8080:80 \
-v "$PWD/site":/usr/share/nginx/html:ro nginx:alpine
curl -s localhost:8080
<h1>version one</h1>
Now edit the file on the host and request the page again. No restart, no rebuild:
echo "<h1>version two, edited on the host</h1>" > ./site/index.html
curl -s localhost:8080
<h1>version two, edited on the host</h1>
The container is serving your host files directly. The :ro on the end mounts them read only, which is a good habit when the container has no business writing back.
Info
Which one do I use? Use a named volume for data the application owns, like a database, a cache, or uploaded files. You do not care where on disk it lives, only that it persists and is easy to back up. Use a bind mount when you need a specific host folder, most often your source code during development or a config file you edit by hand. Rule of thumb: named volumes for state, bind mounts for code and config.
Back up and restore a volume
Because a named volume is just a directory Docker manages, you can back it up by running a tiny throwaway container that mounts the volume and tars it to a folder on your host.
Back it up:
docker run --rm \
-v demo_pgdata:/data:ro \
-v "$PWD/backup":/backup \
alpine tar czf /backup/demo_pgdata.tar.gz -C /data .
du -h backup/demo_pgdata.tar.gz
6.4M backup/demo_pgdata.tar.gz
The alpine container mounts the volume at /data and your host folder at /backup, tars one into the other, and exits. Restore is the same move in reverse: create a fresh volume and untar into it.
docker volume create demo_pgdata_restored
docker run --rm \
-v demo_pgdata_restored:/data \
-v "$PWD/backup":/backup \
alpine sh -c "tar xzf /backup/demo_pgdata.tar.gz -C /data"
Prove the restored copy is real by running Postgres on it and checking the row:
docker run -d --name pg3 \
-e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
-v demo_pgdata_restored:/var/lib/postgresql/data postgres:16-alpine
docker exec pg3 psql -U demo -d demo -c "SELECT body FROM notes;"
body
------------------------------
survives a container rebuild
(1 row)
You can wrap these two commands in small backup-volume.sh and restore-volume.sh scripts to reuse the pattern on any volume.
Warning
For a live database, prefer its own dump tool. Tarring the volume is perfect for static data and for moving a volume between machines. For a database that is actively being written to, a consistent backup is safer with the database's own tool (
pg_dumpfor Postgres) so you do not capture a half-written file. Tar the volume when the container is stopped, or usepg_dumpwhile it runs.
Networks: containers that find each other by name
By default, containers you start with docker run land on the default bridge network, where they can only reach each other by IP address. Create your own user-defined network and you get something much better: Docker runs an embedded DNS server, so containers resolve each other by name.
docker network create appnet
docker run -d --name web1 --network appnet nginx:alpine
From another container on the same network, look up web1 by name:
docker run --rm --network appnet busybox nslookup web1
Address: 127.0.0.11:53
Non-authoritative answer:
Name: web1
Address: 172.18.0.2
That answer came from Docker's built-in resolver at 127.0.0.11. And because the name resolves, you can just talk to the service:
docker run --rm --network appnet alpine \
sh -c "wget -qO- http://web1 | grep -i '<title>'"
<title>Welcome to nginx!</title>
docker network inspect shows who is attached:
docker network inspect appnet --format '{{range .Containers}}{{.Name}} -> {{.IPv4Address}}{{println}}{{end}}'
web1 -> 172.18.0.2/16
This is exactly why Compose works the way it does: Compose puts your services on a user-defined network automatically, which is why the API in the Compose post could connect to postgres by name without ever knowing its IP.
Warning
The default bridge has no name resolution. Containers started without
--networkshare the default bridge, where DNS by name does not work; you would have to use IP addresses, which change. Always create a user-defined network (or use Compose, which does it for you) when containers need to talk.
Doing it in Compose
You rarely type these flags by hand for a real app. In Compose, a named volume and a bind mount look like this, and the network is created for you:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: demo
POSTGRES_PASSWORD: demo
POSTGRES_DB: demo
volumes:
- pgdata:/var/lib/postgresql/data # named volume: managed by Docker, survives rebuilds
web:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro # bind mount: serves live files from the host
volumes:
pgdata:
docker compose up creates the pgdata volume, wires the bind mount, and puts both services on a shared network where web could reach db by name.
Common gotchas
My data still disappeared
You probably used an anonymous volume or none at all. Check that the -v name:/path left side is a real volume name, and that the path on the right is where the app actually writes (/var/lib/postgresql/data for Postgres). docker volume ls shows your named volumes.
docker compose down -v wiped my database
That is what -v does: it removes named volumes along with the containers. Use plain docker compose down to keep your data, and reserve -v for a deliberate clean slate.
Bind mount shows an empty directory
The host path was wrong or did not exist. Docker creates a missing bind-mount source as an empty directory rather than failing, so a typo silently mounts nothing. Use an absolute path (or $PWD/...) and confirm the folder exists.
Permission denied inside the container
The container process runs as a specific user, and bind-mounted host files keep their host ownership. If the container cannot read or write them, line up the ownership, or use a named volume (which Docker initializes with the right permissions) instead.
Containers cannot reach each other
They are on the default bridge. Put them on the same user-defined network (docker network create then --network, or let Compose do it) so name resolution works.
Where to go next
Your data now outlives your containers and your services can find each other. The series continues with getting images off your machine and operating containers day to day.
- Next in this series: Docker Images and Registries, tagging and pushing an image so you can pull it anywhere.
Verified on 2026-09-10 on a real Ubuntu 24.04.5 LTS system (arm64) with Docker Engine 29.8.0. Captured: a named volume (demo_pgdata) retaining a Postgres row across a full docker rm -f and recreate; a bind mount serving version one then version two after a host edit with no restart; a volume backed up to a 6.4M tar.gz via an alpine sidecar and restored into a new volume with the row intact; and on a user-defined network, nslookup web1 resolving to 172.18.0.2 via Docker's 127.0.0.11 resolver plus wget http://web1 returning the nginx welcome page.
Top comments (0)