Skip to main content

πŸ”Œ 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 ports and using internal networks
  • 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 docker inspection 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.

graph TD App[Application Stack] --- Net[Networks] App --- Vol[Volumes] Net --- N1[Service discovery] Net --- N2[Container-to-container traffic] Net --- N3[Isolation between tiers] Vol --- V1[Data persistence] Vol --- V2[Shared files] Vol --- V3[Live code for dev]

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:

  • web can reach api at http://api:3000
  • api can reach db at db:5432
  • The host (your laptop) can reach web at localhost:8080 and api at localhost:3000, because those ports are published
  • The host cannot reach db directly, 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.

graph TD subgraph Default Network Web[web] Api[api] Db[db] end Web <--> Api Api <--> Db Host[Your Laptop] -->|"published 8080"| Web Host -->|"published 3000"| Api

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.

graph LR Client[External User] --> Web[web] subgraph frontend network Web Api1[api] end subgraph backend network internal Api2[api] Db[db] end Web <--> Api1 Api2 <--> Db Api1 -.same container.- Api2

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, bind mounts, and tmpfs compared Named Volume Managed by Docker Persists across restarts Best for databases Portable & backup-able db_data:/var/lib/... Bind Mount A host folder Edits show up live Best for dev & config Host-path dependent ./src:/app tmpfs In memory only Never hits disk Gone on stop For secrets / scratch tmpfs: /tmp
Named volumes persist and are managed by Docker; bind mounts map a host folder; tmpfs lives only in RAM.

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:
graph TD User[External User] --> Nginx[nginx] subgraph frontend Nginx ApiA[api] end subgraph backend internal ApiB[api] Db[db] end Nginx <--> ApiA ApiB <--> Db Db --- Vol[(db_data volume)] ApiB -.uploads bind.- Up[host ./uploads] Nginx -.config bind ro.- Conf[host nginx.conf]

Troubleshooting

When something can't connect or data goes missing, these commands and checks resolve the vast majority of cases.

SymptomLikely causeFix
One container can't reach anotherNot on a shared network, or wrong service nameCheck both networks: lists; verify the hostname matches the service name
Host can't reach a containerNo ports: published, or app bound to 127.0.0.1Add a ports mapping; make the app listen on 0.0.0.0
Data lost after restartNo volume, wrong path, or ran down -vAdd a named volume on the correct container path; avoid -v
Code edits don't appearMissing or wrong bind mount pathConfirm 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.

  1. PostgreSQL data that must survive redeploys.
  2. Your API source code, editable live during development.
  3. A decrypted API key that should never be written to disk.
  4. 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 -v casually β€” it deletes your named volumes
  • Bind-mount your host node_modules over 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: true cuts 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 -v deletes named volumes; volume names are project-prefixed

πŸ“š Additional Resources

πŸš€ 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.