DEV Community

Cover image for Where Your Data Lives: Docker Volumes, Bind Mounts, and Networks
Shubham Sharma
Shubham Sharma

Posted on Originally published at techdevmantra.com

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:

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
Enter fullscreen mode Exit fullscreen mode

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');"
Enter fullscreen mode Exit fullscreen mode
CREATE TABLE
INSERT 0 1
Enter fullscreen mode Exit fullscreen mode

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;"
Enter fullscreen mode Exit fullscreen mode
 id |             body
----+------------------------------
  1 | survives a container rebuild
(1 row)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<h1>version one</h1>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<h1>version two, edited on the host</h1>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
6.4M    backup/demo_pgdata.tar.gz
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;"
Enter fullscreen mode Exit fullscreen mode
             body
------------------------------
 survives a container rebuild
(1 row)
Enter fullscreen mode Exit fullscreen mode

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.

docker network create appnet
docker run -d --name web1 --network appnet nginx:alpine
Enter fullscreen mode Exit fullscreen mode

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

docker run --rm --network appnet busybox nslookup web1
Enter fullscreen mode Exit fullscreen mode
Address:    127.0.0.11:53
Non-authoritative answer:
Name:   web1
Address: 172.18.0.2
Enter fullscreen mode Exit fullscreen mode

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>'"
Enter fullscreen mode Exit fullscreen mode
<title>Welcome to nginx!</title>
Enter fullscreen mode Exit fullscreen mode

docker network inspect shows who is attached:

docker network inspect appnet --format '{{range .Containers}}{{.Name}} -> {{.IPv4Address}}{{println}}{{end}}'
Enter fullscreen mode Exit fullscreen mode
web1 -> 172.18.0.2/16
Enter fullscreen mode Exit fullscreen mode

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:

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:
Enter fullscreen mode Exit fullscreen mode

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)