Where Your Data Lives: Docker Volumes, Bind Mounts, and Networks

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
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
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:
1docker volume create demo_pgdata2docker run -d --name pg \3 -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \4 -v demo_pgdata:/var/lib/postgresql/data postgres:16-alpineGive Postgres a few seconds to initialize, then write a row:
1docker exec pg psql -U demo -d demo \2 -c "CREATE TABLE notes(id serial primary key, body text);3 INSERT INTO notes(body) VALUES ('survives a container rebuild');"1CREATE TABLE2INSERT 0 1Now destroy the container completely, then start a brand new one on the same volume and read the row back:
1docker rm -f pg2docker run -d --name pg2 \3 -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \4 -v demo_pgdata:/var/lib/postgresql/data postgres:16-alpine5docker exec pg2 psql -U demo -d demo -c "SELECT * FROM notes;"1 id | body2----+------------------------------3 1 | survives a container rebuild4(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:
1echo "<h1>version one</h1>" > ./site/index.html2docker run -d --name web -p 8080:80 \3 -v "$PWD/site":/usr/share/nginx/html:ro nginx:alpine4curl -s localhost:80801<h1>version one</h1>Now edit the file on the host and request the page again. No restart, no rebuild:
1echo "<h1>version two, edited on the host</h1>" > ./site/index.html2curl -s localhost:80801<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
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:
1docker run --rm \2 -v demo_pgdata:/data:ro \3 -v "$PWD/backup":/backup \4 alpine tar czf /backup/demo_pgdata.tar.gz -C /data .5du -h backup/demo_pgdata.tar.gz16.4M backup/demo_pgdata.tar.gzThe 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.
1docker volume create demo_pgdata_restored2docker run --rm \3 -v demo_pgdata_restored:/data \4 -v "$PWD/backup":/backup \5 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:
1docker run -d --name pg3 \2 -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \3 -v demo_pgdata_restored:/var/lib/postgresql/data postgres:16-alpine4docker exec pg3 psql -U demo -d demo -c "SELECT body FROM notes;"1 body2------------------------------3 survives a container rebuild4(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
pg_dump for Postgres) so you do not capture a half-written file. Tar the volume when the container is stopped, or use pg_dump while 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.
1docker network create appnet2docker run -d --name web1 --network appnet nginx:alpineFrom another container on the same network, look up web1 by name:
1docker run --rm --network appnet busybox nslookup web11Address: 127.0.0.11:532Non-authoritative answer:3Name: web14Address: 172.18.0.2That 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:
1docker run --rm --network appnet alpine \2 sh -c "wget -qO- http://web1 | grep -i '<title>'"1<title>Welcome to nginx!</title>docker network inspect shows who is attached:
1docker network inspect appnet --format '{{range .Containers}}{{.Name}} -> {{.IPv4Address}}{{println}}{{end}}'1web1 -> 172.18.0.2/16This 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
--network share 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:
1services:2 db:3 image: postgres:16-alpine4 environment:5 POSTGRES_USER: demo6 POSTGRES_PASSWORD: demo7 POSTGRES_DB: demo8 volumes:9 - pgdata:/var/lib/postgresql/data # named volume: managed by Docker, survives rebuilds10 11 web:12 image: nginx:alpine13 ports:14 - "8080:80"15 volumes:16 - ./site:/usr/share/nginx/html:ro # bind mount: serves live files from the host17 18volumes:19 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.
Join the discussion on Where Your Data Lives: Docker Volumes, Bind Mounts, and Networks
Likes, comments, and replies are available for authenticated readers with verified email addresses.


