New: Try Voli The Bear, Fast package manager (and not only) for Windows
Updated Jul 10, 2026 Edit on GitHub

Networking, Volumes & Dev Workflow

Your stack comes up. So why does the API sometimes crash on startup, where exactly does your database's data live, and how do you get code changes to show up without rebuilding the image every time? These three questions - networking, persistence, and the dev loop - are what separate "I got the example running" from "I run my projects on this." This phase answers all three, then names the two traps that bite people once they're comfortable: depends_on not meaning what you think, and the temptation to ship your dev compose file straight to production.

How services find each other: the Compose network

When Compose brings up a stack, it creates a private network and puts every service on it. On that network, each service is reachable by its service name as a hostname. The service called db in your file is literally reachable at the address db from any other service in the stack. Coming from running containers by hand, people expect to wire up IP addresses, --link flags, or hand-managed networks - none of that. The service name is the address. That's the whole mechanism.

Look back at the API's config from Phase 2:

  api:
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/shop
      REDIS_URL: redis://cache:6379

The host in DATABASE_URL is db; the host in REDIS_URL is cache. Those are the service names. When the API opens a connection to db:5432, the Compose network resolves db to the database container and the connection lands. You can watch it from inside the API container:

$ docker compose exec api ping -c 1 db
PING db (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: icmp_seq=0 ttl=64 time=0.089 ms

What just happened: docker compose exec api ... ran a command inside the running api container. From there, db resolved to the database container's address on the private network - no IP addresses written anywhere, no extra configuration. That name-based resolution is what makes the connection strings in your file work.

💡 Key point. This is why the service names you pick in Phase 2 matter: they're the addresses your code uses. Rename db to database in the file and you must change db:5432 to database:5432 in DATABASE_URL too, or the API can't find it.

⚠️ Gotcha - localhost inside a container is the container itself. A reflex from non-Docker life is to point the API at localhost:5432 for the database. Inside a container, localhost means that same container, not your machine and not the database. The API would be looking for Postgres inside itself, find nothing, and fail to connect. Across the stack, you always use service names, never localhost.

Keeping your data: named volumes in practice

You met named volumes in Phase 2. Here's why they matter, made concrete. A container's own filesystem dies with the container. The named volume db_data lives outside the container, so when you recreate the database container, the data is still there. Add some data, recreate the container, and confirm it survived:

$ docker compose down
[+] Running 5/5
 ✔ Container shop-db-1   Removed
 ...
$ docker compose up -d
[+] Running 5/5
 ✔ Container shop-db-1   Started
 ...
$ docker compose exec db psql -U app -d shop -c "SELECT count(*) FROM products;"
 count
-------
    42
(1 row)

What just happened: You tore the whole stack down - destroying the database container - then brought it back up, creating a brand-new db container. The 42 rows are still there because the data never lived in the container; it lived in the db_data volume, which down left untouched. The new container mounted the same volume and found the data waiting.

You can see the volume Docker is keeping for you:

$ docker volume ls
DRIVER    VOLUME NAME
local     shop_db_data

What just happened: Compose named the volume by combining the project name (shop) with the volume name from your file (db_data). That's the storage that outlives your containers.

⚠️ Gotcha - down -v deletes it. As flagged in Phase 2: docker compose down keeps volumes, but docker compose down -v destroys them. That -v is how the 42 rows above would vanish for good. Use it when you want a clean slate; never use it when you don't.

The dev loop: bind-mounts for live-reload

A named volume is storage Docker manages. A bind-mount is different: it maps a folder on your machine directly into the container, so the container sees your real, live source files. Edit a file in your editor and the container sees the change instantly - no image rebuild.

📝 Bind-mount. A mapping from a path on your host into a path in the container. Unlike a named volume (managed by Docker, for persistence), a bind-mount points at a specific folder you control, used mostly to share your source code into a container during development.

Without it, your workflow during development is brutal: change one line, rebuild the image, recreate the container, see the change. With a bind-mount plus a dev server that watches for file changes, you change a line and it's live in the container immediately. You add a bind-mount to the service running your code:

  api:
    build: ./api
    volumes:
      - ./api:/app          # bind-mount: your ./api folder → /app in the container
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/shop
      REDIS_URL: redis://cache:6379

What just happened: ./api:/app maps your local ./api directory onto /app inside the container. Now the code running in the container is the code in your editor. Pair that with a watch-and-reload dev server inside the container and saving a file reloads the app - the fast inner loop you actually want when building.

💡 Telling the two apart. A volume entry with a path on the left (./api:/app) is a bind-mount - it points at a folder you can see. A volume entry with a name on the left (db_data:/var/lib/postgresql/data) is a named volume - Docker manages it and you declare it in the top-level volumes: block. Same volumes: key in the service, two different tools.

Trap #1: depends_on waits for start, not readiness

We flagged this in Phase 2; here's the real fix. depends_on guarantees the database container starts before the API container. It does not guarantee Postgres inside that container has finished initializing and is accepting connections. So this can happen:

$ docker compose up
db-1   | database system is starting up
api-1  | Error: connect ECONNREFUSED db:5432
api-1  | exited with code 1
db-1   | database system is ready to accept connections

What just happened: Compose did its job - it started db before api. But the API came up a fraction of a second faster than Postgres finished initializing, tried to connect to a database that wasn't listening yet, and crashed. depends_on was satisfied; the database just wasn't ready.

The real fix: a healthcheck plus a condition. Teach Compose how to know the database is genuinely ready, then make the API wait for that:

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: shop
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d shop"]
      interval: 5s
      timeout: 3s
      retries: 5

  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy

What just happened: The healthcheck runs pg_isready - Postgres's own "are you ready for connections?" probe - every 5 seconds until it succeeds, marking db healthy. The longer depends_on form, condition: service_healthy, tells Compose to hold the API back until db reports healthy, not just started - so the API only launches once the database can actually answer it.

💡 Key point. "It starts before it" and "it's ready before it" are different promises. depends_on alone makes the first; a healthcheck with condition: service_healthy makes the second. For anything the next service immediately connects to - a database especially - you want the second.

Trap #2: your dev compose is not your prod compose

The file we've built is excellent for development and wrong for production, on purpose. The very things that make local dev pleasant are the things you must not ship.

Run your eye down what's dev-only in this file:

   DEV (great locally)              PROD (must change)
   ───────────────────             ───────────────────
   passwords in plain text   ──►   secrets from a vault / env, not in Git
   bind-mount ./api:/app     ──►   no bind-mount - run the built image
   no resource limits        ──►   memory/CPU limits set
   "build: ./api" locally    ──►   a versioned, pre-built, pushed image
   ports exposed freely      ──►   only what must be public is public

⚠️ Gotcha - don't ship the dev file unchanged. The bind-mount ./api:/app means "run whatever code is in this folder" - in production there is no such folder, and even if there were, you want to run the exact, tested, built image, not live-edited files. The plaintext password is a development convenience and a production liability. Copying your dev docker-compose.yml to a server and running it is one of the most common ways people get burned.

The calm way to handle it. Keep the shared, true-everywhere parts in docker-compose.yml, and put the dev-only conveniences (bind-mounts, exposed debug ports, the throwaway password) in a separate docker-compose.override.yml that Compose merges in automatically only during local development. Production runs the base file with its own settings layered on instead. The mental model that matters: one file is not meant to serve both worlds - keep the differences explicit. (Full override mechanics and a production-grade setup are deeper material for a follow-up guide; for now, just don't deploy the dev file as-is.)

📝 A note on the word "production." Production means the real environment serving real users - where a leaked password or live-edited code isn't a learning moment, it's an incident. The bar there is genuinely different from your laptop, and pretending otherwise is how good developers have bad days.

Recap

  1. Services reach each other by service name on the private network Compose creates - never by IP, never by localhost.
  2. Named volumes keep your data alive across container recreation; down keeps them, down -v destroys them.
  3. Bind-mounts (./folder:/path) share your live source into the container for fast edit-and-reload development - distinct from named volumes.
  4. depends_on means started, not ready - for a database the next service connects to, add a healthcheck and condition: service_healthy.
  5. Dev compose ≠ prod compose - bind-mounts, plaintext secrets, and build: are dev conveniences; never deploy the dev file unchanged.

You can now run a real multi-service stack, understand how it talks to itself, keep its data safe, develop against it quickly, and avoid the two traps that catch people once they're comfortable. That's the whole everyday skill - go run something real on it.


← Phase 2: The compose file · Guide overview

Related: Docker Without the Magic · Environment Variables & Config

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. How do services find each other on a Compose network?

2. The real fix for "the API crashes because the DB is not ready yet" is...

3. Your dev compose file should not be shipped to production because...