Docker Basics #4: Volumes and Networks — Data and Communication
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:
- #1 What is a container
- #2 Writing your first Dockerfile
- #3 Images and containers — build, run, ps, logs, exec
- #4 Volumes and networks ← this post
- #5 Registries — Docker Hub, GHCR, push/pull
- #6
.dockerignoreand the build context
A container’s filesystem is ephemeral #
One fact first. Files created inside a container disappear with the container.
Easy to verify:
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 directoryA 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:
┌─────────────────────────────────────────────────────────┐
│ 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 mount | named volume | |
|---|---|---|
| Host path | You pick it | Docker-managed area (/var/lib/docker/volumes/) |
| Backup / move | Move the host directory | docker volume commands or docker run |
| Permissions | Same as host user | Docker handles them |
| Main use | Development — code hot reload | Production — DB data, uploads |
| OS portability | Path differs by OS | Behaves 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.
docker run --rm -it \
-p 8000:8000 \
-v $(pwd):/app \
hello-dockerWhat -v $(pwd):/app says:
$(pwd)— current host directory:separator/app— mount point inside the container (matchesWORKDIR)
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:
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:
docker run --rm -v $(pwd)/config:/etc/myapp:ro myappCommon 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.
docker volume create pgdataMount it into a PostgreSQL container:
docker run -d --name pg \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16The 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:
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 #
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 volumesdocker 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:
docker network ls
# NETWORK ID NAME DRIVER SCOPE
# xxx bridge bridge local
# yyy host host local
# zzz none null local| Mode | Meaning |
|---|---|
| bridge (default) | Containers attach to a Docker-created virtual bridge and get their own IPs |
| host | Container reuses the host’s network stack. Less isolation. |
| none | No 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.
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:
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) ... ← worksThe 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 #
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 networksPort mapping — -p in depth
#
-p has a few shapes:
-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 8000A common production pattern is 127.0.0.1 binding:
docker run -d -p 127.0.0.1:5432:5432 postgres:16Plain -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,
-pmaps 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.
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 myappEven 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.
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.
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:
# 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 pgMost “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.
-pis host ↔ container mapping; container-to-container talks the internal port directly.- Don’t
-pcontainers 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.