π Networking and Volumes
Two invisible systems make a multi-container app actually work. Networking lets containers find and talk to each other β without it they're isolated silos. Volumes let data outlive the containers that create it β without them your database resets to empty every time it restarts. This lesson demystifies both.
Week 11 · Day 3 (Wednesday: Docker Compose) · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain how the default bridge network gives every service DNS-by-name
- Define custom networks to isolate a frontend tier from a backend tier
- Keep a database off the host by omitting
portsand usinginternalnetworks - Choose correctly between named volumes, bind mounts, and tmpfs
- Persist database data safely and mount code read-only where appropriate
- Diagnose connectivity and storage problems with
dockerinspection commands
Estimated Time: 65 minutes
Practice: Build a two-network stack that isolates the database, and persist its data with a named volume.
In This Lesson
The Two Invisible Systems
You've already used both networking and volumes in earlier lessons β services reached each other by name, and a database kept its data across restarts. Now we look under the hood so you can shape these systems deliberately rather than relying on the defaults.
ποΈ The city-utility analogy
Networks are the roads connecting buildings (containers). Some roads are private, internal to a neighborhood; others connect out to the highway. Volumes are the water and power lines β infrastructure that persists even when a building is renovated or torn down and rebuilt. Your data flows through those permanent lines, unaffected by containers coming and going.
The Default Network & DNS
When you run docker compose up, Compose does something helpful automatically: it creates a single bridge network named after your project and connects every service to it. On that network, each service is reachable by its service name, because Compose runs an embedded DNS server that resolves those names to the right container.
services:
web:
image: nginx:alpine
ports:
- "8080:80" # published to the host
api:
build: ./api
ports:
- "3000:3000" # published to the host
db:
image: postgres:16
# No ports: β not reachable from the host, only from other services
With just that file, here's what's true β and worth reading carefully, because these four facts explain most of Compose networking:
webcan reachapiathttp://api:3000apican reachdbatdb:5432- The host (your laptop) can reach
webatlocalhost:8080andapiatlocalhost:3000, because those ports are published - The host cannot reach
dbdirectly, because it publishes no ports β which is exactly what you want for a database
β οΈ ports vs. internal reachability
A common misconception: that you must publish a port for one container to reach another. You don't. Every service on the network can already reach every other service's container ports by name. ports: is only about exposing a service to the host machine. Leaving ports off your database is a feature, not an omission.
Custom Networks for Isolation
The single default network is fine for small apps, but sometimes you want stronger boundaries β for example, ensuring the web tier physically cannot reach the database, only the API can. You get that by defining your own networks and attaching each service only to the ones it needs.
services:
web:
image: nginx:alpine
ports:
- "80:80"
networks:
- frontend # only on the frontend network
api:
build: ./api
networks:
- frontend # talks to webβ¦
- backend # β¦and to the database
db:
image: postgres:16
networks:
- backend # only on the backend network
networks:
frontend:
backend:
internal: true # no route to the outside world at all
Because web lives only on frontend and db lives only on backend, they have no shared network and literally cannot reach each other. The api bridges both. Marking backend as internal: true goes further: containers on it get no outbound route to the internet, tightening the blast radius if the database were ever compromised.
A couple of useful network options
networks:
backend:
driver: bridge
internal: true # no external connectivity
shared_net:
external: true # attach to a network created outside this project
π‘ Network aliases
A service can answer to extra names on a network via aliases, handy when migrating config that expects a different hostname:
services:
db:
image: postgres:16
networks:
backend:
aliases:
- database
- primary-db
Now other services on backend can reach it as db, database, or primary-db.
Why Volumes Exist
A container's own filesystem is ephemeral. Anything written inside it lives in a throwaway writable layer that is destroyed when the container is removed. Run a database, docker compose down, then up again with no volume, and every row is gone. Volumes are the mechanism for keeping data that must survive the container.
π¦ The moving-boxes analogy
The container filesystem is a rental apartment β it resets to blank when you leave. A volume is your moving boxes: they hold your belongings and travel with you from apartment to apartment. A bind mount is built-in furniture that belongs to the building but that you can use while you live there. Recreate the container (move apartments) and your volume data comes along; the ephemeral environment is what gets refreshed.
Named Volumes vs. Bind Mounts
Compose gives you three ways to attach storage. The first two are the ones you'll use daily; the third is a niche tool.
Named volumes β for data that must persist
Docker creates and manages the storage; you refer to it by a name. This is the correct choice for databases and any application data you can't afford to lose.
services:
db:
image: postgres:16
volumes:
- db_data:/var/lib/postgresql/data # named volume β survives down/up
volumes:
db_data: # declared at the top level so Docker manages it
Bind mounts β for live code and config in development
A bind mount maps a folder on your host straight into the container. Edit a file on your machine and the container sees the change instantly β the foundation of hot-reloading dev setups. It's also how you inject config files.
services:
api:
build: ./api
volumes:
- ./api:/app # your source, live-editable inside the container
- /app/node_modules # anonymous volume shields container's deps
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro # config, read-only
π‘ The node_modules trick
When you bind-mount ./api onto /app, your host folder (which usually has no node_modules, or the wrong platform's build) shadows the ones installed during the image build. Adding a bare /app/node_modules mount creates an anonymous volume that preserves the container's installed dependencies underneath your live source. It's a small line that saves a lot of confusion.
Read-only and tmpfs
Append :ro to make any mount read-only β ideal for config a container should never modify. For sensitive or purely temporary data that should never touch disk, mount a tmpfs (RAM-backed) volume:
services:
app:
image: myapp
volumes:
- ./config:/app/config:ro # read-only
tmpfs:
- /tmp # in-memory scratch space, wiped on stop
β The one-line rule of thumb
Named volume for data you must keep. Bind mount for files you actively edit. tmpfs for secrets and scratch. Ninety percent of decisions follow from that.
Putting It Together
Here's a stack that uses everything above: an isolated internal backend network, a named volume for Postgres data, a read-only bind mount for the reverse proxy's config, and a bind mount for uploads.
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro # read-only config
- ./web/dist:/usr/share/nginx/html:ro # static build
networks:
- frontend
depends_on:
- api
api:
build: ./api
expose:
- "3000" # internal only, not published
environment:
DATABASE_URL: postgres://app:secret@db:5432/appdb
volumes:
- ./uploads:/app/uploads # bind mount for user uploads
networks:
- frontend
- backend
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data # named volume: persistence
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # seed, read-only
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 5
networks:
frontend:
backend:
internal: true # database is unreachable from outside
volumes:
db_data:
Troubleshooting
When something can't connect or data goes missing, these commands and checks resolve the vast majority of cases.
| Symptom | Likely cause | Fix |
|---|---|---|
| One container can't reach another | Not on a shared network, or wrong service name | Check both networks: lists; verify the hostname matches the service name |
| Host can't reach a container | No ports: published, or app bound to 127.0.0.1 | Add a ports mapping; make the app listen on 0.0.0.0 |
| Data lost after restart | No volume, wrong path, or ran down -v | Add a named volume on the correct container path; avoid -v |
| Code edits don't appear | Missing or wrong bind mount path | Confirm the ./host:/container mapping and that the app watches files |
# Inspect networks
docker network ls
docker compose exec api getent hosts db # does "db" resolve?
docker compose exec api ping -c1 db
# Inspect volumes
docker volume ls
docker volume inspect <project>_db_data
# What's mounted into a container?
docker inspect <container> --format '{{ json .Mounts }}'
# Follow logs to see connection errors
docker compose logs -f api
β οΈ Volume names are project-prefixed
A volume you call db_data is created as <projectname>_db_data (the project name defaults to the folder). That's why docker volume ls shows a prefixed name β expected, not a bug. It also means two different project folders get separate volumes even if both call them db_data.
Practice & Quiz
ποΈ Exercise 1: Isolate the database
Goal: Write a compose.yaml with web, api, and db where web cannot reach db at all, and the database data persists.
π‘ Hint
Put web on a frontend network only, db on a backend network only, and api on both. Give db a named volume. Mark backend as internal: true for extra safety.
β Solution
services:
web:
image: nginx:alpine
ports:
- "80:80"
networks:
- frontend
api:
build: ./api
networks:
- frontend
- backend
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
volumes:
- db_data:/var/lib/postgresql/data
networks:
- backend
networks:
frontend:
backend:
internal: true
volumes:
db_data:
Verify the isolation: docker compose exec web getent hosts db should fail to resolve, while the same command from api succeeds.
ποΈ Exercise 2: Right storage for the job
Goal: For each need, name the correct storage type β named volume, bind mount, or tmpfs.
- PostgreSQL data that must survive redeploys.
- Your API source code, editable live during development.
- A decrypted API key that should never be written to disk.
- An Nginx config file the container must not modify.
β Solution
- (1) Named volume β Docker-managed persistence.
- (2) Bind mount β maps your host folder for live edits.
- (3) tmpfs β RAM-only, never touches disk.
- (4) Bind mount with
:roβ inject the file, read-only.
π― Quick Quiz
Question 1: To let the host machine reach a container's port, you must add aβ¦
Question 2: Which storage type should hold a production database's data?
Question 3: Two services can communicate only if theyβ¦
Best Practices & Pitfalls
β Do
- Let services find each other by service name over the default network
- Use separate networks to isolate tiers; mark backend networks
internal: true - Publish only the ports the host truly needs β omit them on databases
- Use named volumes for any data you can't lose
- Mount config and code read-only (
:ro) when the container shouldn't change them - Back up important volumes; document what each one holds
β Don't
- Publish a database port to the host unless you specifically need external access
- Rely on bind mounts for production data β they're host-dependent
- Run
docker compose down -vcasually β it deletes your named volumes - Bind-mount your host
node_modulesover the container's β shield it with an anonymous volume - Assume publishing a port is required for container-to-container traffic β it isn't
β οΈ The permission gotcha with bind mounts
Files created inside a container are owned by the container's user (often root), which can leave host files you can't edit, or the reverse β a container that can't write to a bind-mounted folder. When permissions bite, check the user IDs on both sides and set the container's user or fix ownership rather than loosening permissions blindly.
Summary
π Key Takeaways
- Compose auto-creates a default bridge network where services resolve each other by name
ports:exposes a service to the host; container-to-container traffic needs no publishing- Custom networks isolate tiers;
internal: truecuts off outside access entirely - Container filesystems are ephemeral β volumes are how data survives
- Named volumes persist data, bind mounts share host files for dev, tmpfs stays in RAM
down -vdeletes named volumes; volume names are project-prefixed
π Additional Resources
- Docker Docs β Networking in Compose
- Docker Docs β Volumes
- Docker Docs β Bind mounts
- Docker Docs β Compose networks reference
π What's Next?
You can now design the connections and storage of a Compose stack with intent. Next we turn Compose into a smooth day-to-day tool: development workflows with Docker β hot reloading, debugging inside containers, and override files that keep dev and production configurations clean.
π Excellent work!
Networking and volumes are the two systems beginners most often get wrong. You now understand both well enough to build isolated, persistent, production-shaped stacks.