Networks and Volumes
How containers talk to each other and what outlives them.
Docker Networks and Volumes
Two questions come up invariably as soon as you move beyond a single isolated container, and together they account for most of the cryptic error messages in the first few months.
The first: “Why can’t my application reach its database?” You started both containers, each one is running, docker ps looks healthy, and the API stubbornly answers connection refused on localhost:5432. The instinct is to look up an IP address, hardcode it, see that it works—until the next restart, when the IP has changed. The problem wasn’t the address: it’s that localhost, inside a container, doesn’t refer to the same machine as it does on the host.
The second: “Why did my data disappear?” A Postgres database filled over two days of testing, a docker compose down -v typed without thinking, and everything is gone. That’s not an accident: it’s documented behavior. A container is ephemeral by design, and its writable layer dies with it. Persistence is never implicit; you have to declare it.
Both questions have the same root cause: a container is isolated by default, on the network as well as in storage. That isolation is what makes Docker reproducible; networks and volumes are the two explicit mechanisms that poke holes in it, exactly where you decide.
Network Drivers, and Which One to Choose
| Driver | Use | Note |
|---|---|---|
bridge | The default. A private virtual network on the host. | The only one you need in 95% of cases. |
host | The container shares the host’s network stack. | No isolation, no port mapping. Linux only. |
none | No network, apart from the loopback interface. | Purely local processing. |
macvlan | Its own MAC address on the physical network. | Special network integration. |
overlay | A network spanning multiple hosts. | Reserved for Swarm clusters. |
# Les reseaux existants. bridge, host et none sont crees a l'installation.
docker network ls
# Le detail : plage d'adresses, passerelle, conteneurs connectes
docker network inspect bridge
The host driver saves a few microseconds of latency by removing address translation, at the cost of all isolation: the container directly occupies the host’s ports, with the collisions that implies. It’s a last resort, not a default optimization. And it only applies on Linux: on macOS and Windows, the “host” stack would be that of the intermediate VM.
The Point That Unlocks Everything: Default Bridge vs. Custom Bridge
Here is the most useful difference in this lesson, and it is almost always overlooked.
Any container started without further specification joins the default bridge network. On that network, DNS resolution by container name does not work.
docker run -d --name serveur nginx:1.27-alpine
docker run --rm alpine:3.20 ping -c 2 serveur
# ping: bad address 'serveur' -> echec attendu
Create a network yourself, and the behavior changes completely: Docker attaches an internal DNS server to it that resolves container names.
docker network create app-net
docker run -d --name serveur --network app-net nginx:1.27-alpine
docker run --rm --network app-net alpine:3.20 ping -c 2 serveur
# 2 packets transmitted, 2 packets received -> succes
# La requete HTTP passe sur le port INTERNE (80), sans qu'aucun
# port n'ait ete publie sur l'hote.
docker run --rm --network app-net alpine:3.20 \
sh -c "apk add --no-cache curl >/dev/null && curl -s -o /dev/null -w '%{http_code}\n' http://serveur"
The rule to remember: always create a named network for your applications. That’s what Docker Compose does automatically—the <project>_default network—and it’s why, in the previous lesson, the API reached its database at db:5432 with no configuration. It also solves the IP address problem in one stroke: the internal DNS follows the containers, and a service recreated with a new IP remains reachable under the same name, immediately.
A container can be connected to multiple networks, on the fly:
docker network create back-net
docker network connect back-net serveur
docker network disconnect back-net serveur
docker inspect serveur --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
Publishing a Port: What -p Actually Does
-p 8080:80 doesn’t open the container’s port 80—it was already open inside its network. What -p does is install an address translation rule on the host. The host:container order is easy to remember by the direction of traffic: you come from the host, you go to the container.
# Port 8080 de l'hote -> port 80 du conteneur
docker run -d -p 8080:80 nginx:1.27-alpine
# Se lier uniquement a l'interface de bouclage : accessible depuis
# la machine, INVISIBLE depuis le reseau local.
docker run -d -p 127.0.0.1:8080:80 nginx:1.27-alpine
# Laisser Docker choisir un port libre sur l'hote
docker run -d -p 80 nginx:1.27-alpine
# Publier tous les ports declares par EXPOSE
docker run -d -P nginx:1.27-alpine
# Voir les correspondances effectives
docker port <conteneur>
The second form deserves special emphasis, because it fixes a security problem that many people discover too late. -p 8080:80 binds by default to 0.0.0.0, that is, to all interfaces, including the public address. And on Linux, Docker writes its translation rules into an iptables chain evaluated before those of an application firewall like ufw: a port published by Docker can therefore remain reachable from the Internet even though ufw status claims to block it. This isn’t a bug, it’s the order of the filtering chains—but the surprise is unpleasant when the database in question had no password.
The best practice fits in one sentence: only publish what must be reachable from the outside, and prefix with 127.0.0.1 anything that only needs to be reachable locally.
services:
web:
ports:
- "80:8080" # public : c'est voulu
db:
ports:
- "127.0.0.1:5432:5432" # local uniquement, pour un client graphique
cache:
expose:
- "6379" # documentation seulement, rien de publie
A note on localhost, the source of the first problem raised in the introduction. Inside a container, localhost refers to the container itself. An application looking for its database on localhost:5432 is therefore looking for a Postgres inside itself; the right address is the service name, db:5432. And if a container really does need to reach a service running on the host, there is a dedicated name:
# Sur Docker Desktop (macOS, Windows), ce nom est fourni d'office
docker run --rm alpine:3.20 ping -c 1 host.docker.internal
# Sur Linux, il faut le declarer explicitement
docker run --rm --add-host=host.docker.internal:host-gateway \
alpine:3.20 ping -c 1 host.docker.internal
Segmenting the Stack with the Network
Since a container can only reach the networks it belongs to, networks are a real isolation tool. The classic pattern separates an exposed layer from an internal one.
services:
proxy:
image: nginx:1.27-alpine
ports:
- "80:80"
networks:
- frontend # seul service joignable de l'exterieur
api:
build: .
networks:
- frontend # joignable par le proxy
- backend # peut joindre la base
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend # UNIQUEMENT le reseau interne
networks:
frontend:
backend:
internal: true # aucune route vers l'exterieur
volumes:
db-data:
The benefit isn’t theoretical. The proxy doesn’t belong to backend: even if compromised, it can’t open a TCP connection to db, because no route exists. The internal: true option goes further by removing the default route to the outside, which greatly limits data exfiltration from that network.
docker compose exec proxy sh -c "nc -zv db 5432" # doit echouer
docker compose exec api sh -c "nc -zv db 5432" # doit reussir
Three Ways to Persist Data, and When to Use Each
| Type | Managed by | Location | Main use |
|---|---|---|---|
| Named volume | Docker | /var/lib/docker/volumes/... | Production data: databases, uploads |
| Bind mount | You | Anywhere on the host | Development: source code, configuration |
| tmpfs | Docker | In RAM only | Sensitive or temporary data |
A named volume is a space that Docker creates and manages. You don’t know its real path, and that’s precisely the point: it’s portable, independent of the host’s directory structure, can be backed up with Docker tools, and works identically on all three operating systems.
A bind mount mounts an existing host directory into the container. It’s perfect for development—you edit a file, and the container sees it instantly—and unsuitable for production, because it couples the application to the exact directory layout of a specific machine.
# Volume nomme : Docker gere l'emplacement
docker run -d --name pg1 \
-e POSTGRES_PASSWORD=secret \
-v pg-data:/var/lib/postgresql/data \
postgres:16-alpine
# Bind mount : chemin ABSOLU de l'hote, obligatoirement
docker run -d --name web1 \
-v "$(pwd)/site:/usr/share/nginx/html:ro" \
-p 8080:80 nginx:1.27-alpine
# tmpfs : en memoire, disparait a l'arret
docker run -d --name calcul --tmpfs /tmp:rw,size=64m alpine:3.20 sleep 3600
The distinction comes down to one character: -v pg-data:/path without a leading slash designates a named volume, while -v /host/path:/path designates a bind mount. A typo can therefore silently switch from one mechanism to the other. The more verbose --mount syntax removes the ambiguity and fails cleanly if the source doesn’t exist:
docker run -d \
--mount type=volume,source=pg-data,target=/var/lib/postgresql/data \
--mount type=bind,source="$(pwd)/conf",target=/etc/app,readonly \
--mount type=tmpfs,target=/tmp,tmpfs-size=64m \
-e POSTGRES_PASSWORD=secret \
postgres:16-alpine
There is one differing behavior you need to know about, because it explains some baffling situations. When an empty named volume is mounted on a non-empty directory of the image, Docker copies the image’s contents into the volume on first use. A bind mount, by contrast, copies nothing: it simply hides the image’s contents.
# Le volume est pre-rempli avec la page par defaut de nginx
docker run -d --name v1 -v html-vol:/usr/share/nginx/html nginx:1.27-alpine
docker run --rm -v html-vol:/data alpine:3.20 ls /data # index.html present
# Le bind mount d'un dossier vide masque tout : nginx renvoie 403
mkdir -p ./vide
docker run -d --name v2 -p 8081:80 \
-v "$(pwd)/vide:/usr/share/nginx/html" nginx:1.27-alpine
This is exactly why the override file in the previous lesson added an anonymous volume - /app/node_modules: the bind mount of ./src must not hide the dependencies installed in the image.
Managing Volumes Day to Day
# Creer explicitement (facultatif : un volume inconnu est cree a la volee)
docker volume create pg-data
# Lister. Les noms en hexadecimal sont des volumes ANONYMES.
docker volume ls
# Ou vivent reellement les donnees, et qui les utilise
docker volume inspect pg-data
# Supprimer. Refuse si un conteneur l'utilise.
docker volume rm pg-data
# Supprimer tous les volumes inutilises. COMMANDE DESTRUCTRICE :
# listez d'abord, decidez ensuite.
docker volume ls -f dangling=true
docker volume prune
Two points to watch. Volumes survive docker rm: removing a container doesn’t remove its data, which is intentional but explains the silent accumulation of orphaned volumes. And anonymous volumes pile up with unreadable identifiers: always name your own.
To inspect a volume without starting the service that uses it, a throwaway container is the ideal tool:
docker run --rm -v pg-data:/data alpine:3.20 ls -la /data
docker run --rm -v pg-data:/data alpine:3.20 du -sh /data
The Permissions Problem with Bind Mounts
On Linux, there’s a trap that catches everyone once. Permissions rely on numeric identifiers (UID, GID), and these numbers are not translated between the host and the container. If the container writes as user 1001 into a directory mounted from the host, the files created belong to UID 1001 on the host—an account that may not exist. The usual symptom: files owned by root show up in your working directory, or the application complains of permission denied on a directory that you can see is accessible.
# Reproduction : le conteneur ecrit en root
docker run --rm -v "$(pwd):/work" -w /work alpine:3.20 touch fichier-root
ls -l fichier-root # appartient a root
# Solution : imposer l'UID et le GID de l'utilisateur courant
docker run --rm --user "$(id -u):$(id -g)" \
-v "$(pwd):/work" -w /work alpine:3.20 touch fichier-moi
ls -l fichier-moi # vous appartient
# Dans Compose, avec une variable pour rester portable
services:
api:
build: .
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./src:/app/src
This problem doesn’t exist on macOS and Windows, where Docker Desktop’s file-sharing layer rewrites ownership. In exchange, those platforms suffer a performance cost on shared files: a node_modules or .git folder in a bind mount can slow a project down by an order of magnitude. The workaround is to keep those folders in a Docker volume and mount only the source code.
One last useful habit: mount anything the container doesn’t need to modify—configuration, datasets, certificates—as read-only. It’s free and eliminates an entire class of accidents.
docker run -d -v "$(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.27-alpine
Backing Up and Restoring a Volume
A named volume can’t be copied with cp: its location belongs to Docker, and on macOS or Windows it lives inside the VM. The portable method is to mount it in a throwaway container that writes an archive to a host directory.
# --- Sauvegarde : le volume en lecture, un dossier de l'hote en ecriture
docker run --rm \
-v pg-data:/data:ro \
-v "$(pwd):/sauvegarde" \
alpine:3.20 \
tar czf /sauvegarde/pg-data-$(date +%F).tar.gz -C /data .
# --- Restauration
docker volume create pg-data-restaure
docker run --rm \
-v pg-data-restaure:/data \
-v "$(pwd):/sauvegarde" \
alpine:3.20 \
tar xzf /sauvegarde/pg-data-2026-01-15.tar.gz -C /data
An important caveat for databases: copying the files of an engine that is actively writing can produce an inconsistent archive. Stop the container before copying, or, much better, use the engine’s logical backup tool, which guarantees a consistent state:
# Logical backup of Postgres, engine running
docker compose exec -T db pg_dump -U app -d appdb | gzip > appdb-$(date +%F).sql.gz
# Restore
gunzip -c appdb-2026-01-15.sql.gz | docker compose exec -T db psql -U app -d appdb
The -T flag disables pseudo-terminal allocation, which is essential whenever you redirect a stream: without it, the resulting archive is corrupted by the terminal’s control characters.
Networks and Volumes in Compose
Compose declares both at the top level of the file, which gives you a complete view of the topology in one place. The long form is more verbose but much easier to read in a code review.
services:
api:
build: .
networks:
- backend
volumes:
- uploads:/app/uploads # short form: named volume
- type: bind
source: ./config
target: /app/config
read_only: true
- type: tmpfs
target: /tmp
tmpfs:
size: 67108864 # 64 MB, in bytes
networks:
backend:
driver: bridge
volumes:
uploads:
A reminder of the naming pitfall from the previous lesson: Compose prefixes volumes with the project name, so uploads becomes monprojet_uploads. Renaming the project folder creates a new, empty volume and makes it look like your data has disappeared, when it is actually sitting under the old prefix.
docker volume ls | grep uploads # find a volume behind its prefix
docker compose -p monprojet up -d # pin the prefix so you no longer depend on it
Key Takeaways
- On the default
bridgenetwork, name resolution does not work. Always create a named network (which Compose does for you) and reach your services by name, never by an IP address that will change. - Inside a container,
localhostrefers to the container itself. To reach another service, use its name; to reach the host, usehost.docker.internal(with--add-host=host.docker.internal:host-gatewayon Linux). -p 8080:80binds to all interfaces and bypassesufwon Linux. Prefix with127.0.0.1anything that doesn’t need to be public, and don’t publish a database out of habit.- Use a named volume for data that must last, a bind mount for code in development, and tmpfs for anything that must never touch the disk. An empty named volume inherits the image’s contents; a bind mount hides them.
- Volumes outlive the containers that use them.
docker compose downkeeps them,docker compose down -vdestroys them: the difference between the two is your data. - On Linux, UIDs are not translated across a bind mount.
--user "$(id -u):$(id -g)"prevents files owned byrootfrom appearing in your working directory. - Back up a volume using a throwaway container that produces an archive; for a database, always prefer a logical export (
pg_dump) to a hot file copy.
Tip: if you’re unsure about a connection between containers,
docker network inspect <reseau>shows the exact list of attached containers. Nine times out of ten, the service that “isn’t responding” simply isn’t on the same network.
Comments
Comments are powered by GitHub Discussions
Sign in with GitHub to join the discussion