← Back to GuidesGUIDESelf-Hosting & Homelab

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

about 11 hours ago
❤️ 0 likes
💬 0 comments
dockerself-hostinglinux
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

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

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:

Bash
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-alpine

Give Postgres a few seconds to initialize, then write a row:

Bash
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');"
Plain Text
1CREATE TABLE2INSERT 0 1

Now destroy the container completely, then start a brand new one on the same volume and read the row back:

Bash
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;"
Plain Text
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:

Bash
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:8080
Plain Text
1<h1>version one</h1>

Now edit the file on the host and request the page again. No restart, no rebuild:

Bash
1echo "<h1>version two, edited on the host</h1>" > ./site/index.html2curl -s localhost:8080
Plain Text
1<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:

Bash
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.gz
Plain Text
16.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.

Bash
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:

Bash
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;"
Plain Text
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

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_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.

Bash
1docker network create appnet2docker run -d --name web1 --network appnet nginx:alpine

From another container on the same network, look up web1 by name:

Bash
1docker run --rm --network appnet busybox nslookup web1
Plain Text
1Address:	127.0.0.11:532Non-authoritative answer:3Name:	web14Address: 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:

Bash
1docker run --rm --network appnet alpine \2  sh -c "wget -qO- http://web1 | grep -i '<title>'"
Plain Text
1<title>Welcome to nginx!</title>

docker network inspect shows who is attached:

Bash
1docker network inspect appnet --format '{{range .Containers}}{{.Name}} -> {{.IPv4Address}}{{println}}{{end}}'
Plain Text
1web1 -> 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 --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:

YAML
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.

Comments (0)

Loading discussion...

Related guides