Docker Basics #4: Volumes and Networks — Data and Communication

9 min read

With #3 covering the command set, this post turns to two operational topics — data persistence and container-to-container communication.

This post in the Docker Basics series:

A container’s filesystem is ephemeral #

One fact first. Files created inside a container disappear with the container.

Easy to verify:

Demonstrating ephemerality
docker run --rm -it ubuntu:24.04 bash
root@xxx:/# echo "hello" > /tmp/note.txt
root@xxx:/# cat /tmp/note.txt
hello
root@xxx:/# exit

# Step in again
docker run --rm -it ubuntu:24.04 bash
root@yyy:/# cat /tmp/note.txt
cat: /tmp/note.txt: No such file or directory

A new container is a fresh instance built from the image — it knows nothing about files made in a previous one. That’s the core property of containers and the source of their reproducibility: same image, same behavior, anywhere.

The problem is things that need to survive — DB data, uploaded images, logs. Putting that data outside the container is what volumes are for.

Two kinds of mount — bind mount vs. named volume #

Docker offers two persistence mechanisms:

Two mounts
┌─────────────────────────────────────────────────────────┐
│                      Host                               │
│  ┌──────────────────┐      ┌────────────────────────┐   │
│  │ /Users/me/data   │      │ Docker-managed area    │   │
│  │  (you manage)    │      │  (Docker manages)      │   │
│  └────────┬─────────┘      └────────┬───────────────┘   │
│           │ bind mount              │ named volume      │
│           ▼                         ▼                   │
│   ┌─────────────────────────────────────────────────┐   │
│   │            Container — /app/data                │   │
│   └─────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

A bind mount plugs a specific host path straight into the container. Host and container share the same directory.

A named volume stores data in a Docker-managed area and plugs that area into the container. It isn’t tied to any particular host path.

bind mountnamed volume
Host pathYou pick itDocker-managed area (/var/lib/docker/volumes/)
Backup / moveMove the host directorydocker volume commands or docker run
PermissionsSame as host userDocker handles them
Main useDevelopment — code hot reloadProduction — DB data, uploads
OS portabilityPath differs by OSBehaves identically

One-line rule: bind mount in development, named volume in production.

bind mount — code hot reload #

Bring back the Flask app from #2. Rebuilding the image on every code change is wasteful. Bind-mount the host code directory into the container, and the container reads new code as soon as you save on the host.

bind mount
docker run --rm -it \
  -p 8000:8000 \
  -v $(pwd):/app \
  hello-docker

What -v $(pwd):/app says:

  • $(pwd) — current host directory
  • : separator
  • /app — mount point inside the container (matches WORKDIR)

Edit app.py in your editor on the host, save, and /app/app.py in the container changes immediately. Run Flask’s dev server with --reload for auto-restart.

--mount — the more explicit form #

--mount makes the intent more obvious than -v:

--mount form
docker run --rm \
  -p 8000:8000 \
  --mount type=bind,source=$(pwd),target=/app \
  hello-docker

-v is one short line; --mount spells things out as key/value. For new code, --mount is preferred, but -v is convenient for short commands and you’ll see both.

Read-only #

To protect data, append :ro or readonly:

Read-only bind mount
docker run --rm -v $(pwd)/config:/etc/myapp:ro myapp

Common pattern when injecting config files into a container.

Named volumes — production data #

When data has to survive — like a DB container — use a named volume.

Create a named volume
docker volume create pgdata

Mount it into a PostgreSQL container:

postgres + named volume
docker run -d --name pg \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16

The difference: in -v pgdata:/var/lib/postgresql/data, pgdata is a volume name, not a host path. (Docker treats an absolute path before : as a bind mount and a name as a named volume.)

Now you can recreate the container and the data survives:

Recreate container, data persists
docker rm -f pg
docker run -d --name pg \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16
# Same data, intact.

Volume commands #

Working with volumes
docker volume ls               # list volumes
docker volume inspect pgdata   # metadata, host path, etc.
docker volume rm pgdata        # delete (only if unused)
docker volume prune            # sweep unused volumes

docker volume inspect shows where on the host the data lives — usually /var/lib/docker/volumes/pgdata/_data. On macOS / Windows that path lives inside Docker Desktop’s VM, so direct host access is awkward — but the named-volume API behaves identically across systems.

Anonymous volumes #

-v /var/lib/postgresql/data (no name on the left) creates an anonymous volume Docker names randomly. They pile up unless you use docker rm -v to drop them with the container. Always use named volumes in production.

A tour of container networks #

On to networking. The Docker daemon ships with three default networks:

Default networks
docker network ls
# NETWORK ID    NAME      DRIVER    SCOPE
# xxx           bridge    bridge    local
# yyy           host      host      local
# zzz           none      null      local
ModeMeaning
bridge (default)Containers attach to a Docker-created virtual bridge and get their own IPs
hostContainer reuses the host’s network stack. Less isolation.
noneNo network. No outside communication at all.

Day to day everything happens on bridge — but the default bridge and a user-defined bridge behave differently.

The default bridge trap #

A plain docker run puts the container on the default bridge. On that network, containers can’t address each other by name. Only by IP. And the IP changes each time, which is a pain.

Doesn't work (default bridge)
docker run -d --name web nginx
docker run -d --name app myapp

docker exec app ping web
# ping: bad address 'web'

Create a user-defined bridge network and container names act as DNS:

User-defined network
docker network create mynet

docker run -d --name web --network mynet nginx
docker run -d --name app --network mynet myapp

docker exec app ping web
# 64 bytes from web (172.18.0.2) ...  ← works

The name web resolves via DNS. IPs may change; names don’t, so references stay stable.

Practical rule: containers that talk to each other always live on the same user-defined network. Docker Compose does this for you — services in a Compose file share a network and address each other by service name.

Common network commands #

Network command set
docker network create mynet              # create
docker network ls                        # list
docker network inspect mynet             # who's attached
docker network connect mynet myapp       # attach a running container
docker network disconnect mynet myapp    # detach
docker network rm mynet                  # delete
docker network prune                     # sweep unused networks

Port mapping — -p in depth #

-p has a few shapes:

-p variants
-p 8000:8000              # host 8000 → container 8000
-p 80:8000                # host 80   → container 8000
-p 127.0.0.1:8000:8000    # only on the host's 127.0.0.1 (no external access)
-p 8000                   # random host port → container 8000

A common production pattern is 127.0.0.1 binding:

Reachable only locally
docker run -d -p 127.0.0.1:5432:5432 postgres:16

Plain -p 5432:5432 opens 5432 on every interface — if the host is on the internet, external clients can hit it (a firewall usually saves you, but defense in depth helps). For things like a DB that don’t need external access, 127.0.0.1 binding is safer.

Note for macOS/Windows: in Docker Desktop, -p maps to host OS ports directly. So if 5432 is already used by another PostgreSQL on the host, you collide. Change the host side (-p 5433:5432).

Container-to-container talks the “internal port” #

This trips people up.

Setup
docker network create mynet
docker run -d --name pg --network mynet \
  -e POSTGRES_PASSWORD=secret postgres:16     # no -p
docker run -d --name app --network mynet \
  -e DB_HOST=pg -e DB_PORT=5432 myapp

Even without -p on pg, app reaches it as pg:5432. Within the same network, a container’s port is reachable directly by other containers. -p is host ↔ container mapping; container-to-container communication is separate.

Why this matters: in production, the standard is to not -p your DB container. Don’t open a host port — let only app containers on the same network reach it.

Host network and none mode #

Variants you’ll see occasionally.

host network
docker run --rm --network host nginx

--network host lets the container reuse the host’s network stack — no -p needed. But isolation drops, and macOS/Windows behave differently (because of Docker Desktop’s VM boundary). Rarely used outside specific Linux-host operational scenarios.

none — no network
docker run --rm --network none alpine sh

--network none isolates the container from any network. Shows up in CTFs, security isolation, offline processing.

Diagnosis — when traffic doesn’t flow #

The first steps for Docker network troubleshooting:

Diagnostic order
# 1. Same network?
docker inspect myapp --format '{{json .NetworkSettings.Networks}}'

# 2. DNS resolves inside the container?
docker exec myapp getent hosts pg

# 3. Port is reachable?
docker exec myapp nc -zv pg 5432
# Many slim images don't include nc — busybox is a quick alternative
docker run --rm --network mynet busybox nc -zv pg 5432

# 4. Container's own port mapping
docker port pg

Most “I can’t reach it” cases come down to one of four things: wrong network, a typo in the container name, the target container not running, or the app bound to 127.0.0.1 instead of 0.0.0.0.

0.0.0.0 vs. 127.0.0.1 in one paragraph #

A common pitfall. If an app inside the container binds to 127.0.0.1, the port is open only inside that container — host and other containers can’t reach it. Servers inside containers must bind to 0.0.0.0 to accept incoming connections from outside. That’s why the Flask example in #2 used host="0.0.0.0".

Wrap-up #

The picture from this post:

  • Container filesystems are ephemeral. Data that has to survive lives in volumes, outside the container.
  • bind mount plugs a host path straight in — great for development hot reload.
  • named volume lives in Docker-managed storage — right for production data like a DB.
  • On a user-defined bridge network, container names work as DNS — always use one.
  • -p is host ↔ container mapping; container-to-container talks the internal port directly.
  • Don’t -p containers like a DB in production — keep external ports closed and let only app containers on the same network reach them.
  • Servers inside a container must bind to 0.0.0.0.

In the next post (#5 Registries — Docker Hub, GHCR, push/pull) we make the images we built reusable on other machines — the push/pull flow against registries. Docker Hub, GitHub Container Registry, and the rules around image names and tags.

X