# Docker Compose for Real Projects

> Real apps are several containers at once - web, API, database, cache. Compose declares the whole stack in one file and brings it up with a single command.


---

# Docker Compose for Real Projects

You know how to run a single container. You've typed `docker run` enough times that it doesn't scare you anymore. But the moment you try to run a *real* app - the kind with an API, a database, and maybe a cache - you discover that "real" means several containers, all running at once, all needing to find each other. Suddenly you've got four terminal tabs open, you're copying long `docker run` flags between them, and one wrong `--network` argument means the API can't see the database.

That's the wall this guide gets you over. Docker Compose lets you describe your *entire stack* - every service, its image, its ports, its environment, how they connect - in one file, and bring the whole thing up (or down) with one command. This is how teams actually run things. (This very project runs on Compose.)

## How to read this
- **Already know single-container Docker and want the file format fast?** Skip to [Phase 2: The compose file](02-the-compose-file.md) - it's the annotated `docker-compose.yml` you came for.
- **Want it to finally make sense?** Read in order. Phase 1 installs the mental model, Phase 2 builds a real stack, and Phase 3 covers the networking, data, and dev-vs-prod realities that bite people later.

## The phases
1. **[Why One Container Isn't Enough](01-why-one-container-isnt-enough.md)** - what a "stack" really is, why managing it by hand hurts, and the one idea Compose is built on.
2. **[The compose file](02-the-compose-file.md)** - services, images vs build, ports, environment, `depends_on`, and named volumes - with a real `web + db + cache` file and the transcripts of bringing it up, watching logs, and tearing it down.
3. **[Networking, Volumes & Dev Workflow](03-networking-volumes-and-dev-workflow.md)** - how services reach each other by name, how to keep your database's data, live-reload in dev, and the two traps (`depends_on` readiness, and shipping dev config to prod).

> This guide assumes you're comfortable with one container at a time - `docker run`, images, ports. If any of that is fuzzy, read [Docker Without the Magic](/guides/docker-without-the-magic) first; everything here builds on it. Deep production topics - orchestration across many machines, secrets management, multi-stage build tuning - are deliberately left for a follow-up; this guide is about running a real multi-service stack on one machine, cleanly.

**Related:** [Docker Without the Magic](/guides/docker-without-the-magic) · [Environment Variables & Config](/guides/env-vars-and-config)


---

# Why One Container Isn't Enough

When you learned single-container Docker, the app *was* the container. One image, one `docker run`, one thing to think about. That clean picture is exactly why the next step feels like a step backward: real apps aren't one container. They're a small team of them.

Open up almost any web app you'd ship and you'll find the same cast: something serving the front-end, an API doing the work, a database holding the data, often a cache to keep things fast. Each of those is its own container. They start up, they need to find each other, they need to be torn down together. This phase is about seeing that shape clearly - and seeing why the tools you already know start to creak under it. Then we install the one idea that fixes it.

## What a "stack" actually is

A *stack* is the full set of containers that together make up one running application, plus the connections between them. Not one program - a little system of cooperating programs, each in its own container.

📝 **Stack.** Throughout this guide, "stack" means *all the services that have to be running for your app to work*, treated as a single unit you start and stop together.

A typical web app stack looks like this:

```mermaid
flowchart LR
  subgraph App["your app - each box = one container, each arrow = talks to"]
    web["web (nginx)"]
    api["api (node)"]
    db["db (postgres)"]
    cache["cache (redis)"]
    web --> api
    api --> db
    api --> cache
  end
```

The web container takes requests and hands the real work to the API. The API reads and writes the database, and checks the cache before doing slow work. Four containers, three connections - and every one of those connections has to actually *exist* for the app to function.

## Why doing this by hand hurts

"I know `docker run`. I'll just run it four times." You can - and it works right up until it doesn't. Here's what running this stack by hand actually involves. You start the database:

```console
$ docker run -d --name db \
    -e POSTGRES_PASSWORD=secret \
    -e POSTGRES_DB=shop \
    -v shop_data:/var/lib/postgresql/data \
    --network shopnet \
    postgres:16
```

Then the cache, on the same network:

```console
$ docker run -d --name cache --network shopnet redis:7
```

Then the API, which needs to know where the database and cache live, and needs its own port published:

```console
$ docker run -d --name api \
    -e DATABASE_URL=postgres://postgres:secret@db:5432/shop \
    -e REDIS_URL=redis://cache:6379 \
    -p 3000:3000 \
    --network shopnet \
    my-api:latest
```

…and you haven't even started the web container yet, and you had to create that `shopnet` network and `shop_data` volume *first* or every one of those commands fails.

*What just happened:* You hand-assembled the stack one container at a time, manually keeping four things in sync across four commands: the network name (so they can find each other), the volume name (so data survives), the environment variables (so the API knows the database's address), and the start order (the network must exist before anything joins it).

**The gotcha.** None of that is written down anywhere. It lives in your shell history and your head. Tomorrow you'll mistype one flag and spend twenty minutes on "why can't the API reach the database" - when the real answer is you forgot `--network shopnet` on one line. A new teammate has *no* way to reproduce it except you reading commands aloud. And tearing it all down means remembering all four `--name` values to stop and remove. This is the pain Compose exists to kill.

## The one idea: declare the stack, don't perform it

Docker Compose flips the model from *imperative* (you perform a sequence of `run` commands) to *declarative* (you write down what the finished stack should look like, once, in a file called `docker-compose.yml`). Compose reads that file and makes reality match it.

📝 **Declarative.** You describe the desired end state - "these four services, on this network, with these settings" - and the tool figures out the steps to get there. The opposite of typing the steps yourself.

```text
   IMPERATIVE (by hand)                 DECLARATIVE (Compose)
   ─────────────────────                ─────────────────────
   docker run ... db                    docker-compose.yml
   docker run ... cache       ──►         describes the whole
   docker run ... api                     stack, once
   docker run ... web
   (network + volume first!)            $ docker compose up
   (4 commands, kept in sync             (one command reads the
    by hand, every time)                  file and builds it all)
```

Everything you were juggling by hand - the network, the volumes, the environment, the start order, the published ports - becomes a few lines of text. Bringing the entire stack up is one command:

```console
$ docker compose up
```

*What just happened:* Compose read `docker-compose.yml`, created the network and volumes the file describes, and started every service with the right settings and in a sensible order - the same work as those four `docker run` commands, but driven by a file anyone can read, edit, and check into Git.

**Why this saves you later.** The file *is* the documentation. A teammate clones the repo, runs one command, and gets the exact same stack you have - no shell archaeology, no "works on my machine." Six months from now when you've forgotten every detail, the file remembers for you. And tearing the whole thing down is one command too, with nothing left behind. That reproducibility is the entire point.

💡 **Key point.** Compose doesn't do anything you couldn't do with `docker run` by hand. It does the same things, but from a written-down description instead of your memory - and that difference is what makes a multi-service app something a team can actually live with.

## Recap

1. **A real app is a stack** - several cooperating containers (web, API, database, cache), not one.
2. **Each connection between them must really exist** - same network, right addresses, right start order - or the app silently can't talk to itself.
3. **Running a stack by hand is fragile** - the setup lives in your shell history and your head, impossible to reproduce or hand off.
4. **Compose is declarative** - you write down the finished stack once in `docker-compose.yml`, and one command makes it real.
5. **The file is the win** - readable, editable, version-controlled, reproducible by anyone.

Now that you can see the shape of the problem, let's write the file that solves it.


---

# The compose file

This is the file everything else hangs on. Once you can read a `docker-compose.yml` line by line and know what each part *does*, the rest of Compose is just commands that act on it. We'll build one real file - a web server, an API, a database, and a cache - walk every section, then run it for real: bring it up, watch the logs, tear it down clean. Don't memorize the shape; by the end you should be able to look at any compose file and reason about what it's going to do.

## The whole file, top to bottom

Here's a complete, realistic stack. Read it once for the overall shape, then we'll go section by section.

```yaml
services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"
    depends_on:
      - api

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

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: shop
    volumes:
      - db_data:/var/lib/postgresql/data

  cache:
    image: redis:7

volumes:
  db_data:
```

That's the entire stack from Phase 1 - the four `docker run` commands, the network, and the volume - captured in one readable file. Now the tour.

## `services:` - one block per container

The `services` map is the heart of the file. Each key under it (`web`, `api`, `db`, `cache`) is one service - which in practice means one container - and the name you give it matters: it becomes the container's address on the network (more on that in Phase 3). Compose reads each service block and starts a container for it. Choose names well - `api`, `db`, `cache` read clearly, and they're the hostnames your services will use to reach each other.

## `image:` vs `build:` - use someone's, or build your own

Every service needs an image to run. You have two ways to point at one:

- **`image:`** - pull a ready-made image from a registry (like Docker Hub). Use this for off-the-shelf software you don't write: databases, caches, web servers.
- **`build:`** - build the image yourself from a `Dockerfile` in a directory you point at. Use this for *your own* code.

```yaml
  web:
    image: nginx:1.27      # pull nginx, pinned to version 1.27

  api:
    build: ./api           # build from the Dockerfile in ./api
```

*What just happened:* For `web`, Compose pulls the official `nginx:1.27` image as-is. For `api`, Compose looks in `./api`, finds the `Dockerfile`, and builds an image from it - the same as running `docker build ./api`, done for you.

⚠️ **Gotcha - pin your versions.** `image: postgres` (no tag) means "whatever `latest` happens to be today," which can change under you and break things weeks later when you least expect it. Always pin a version - `postgres:16`, `nginx:1.27`, `redis:7` - so the stack you run tomorrow is the stack you ran today.

## `ports:` - open a door from your machine into a container

By default a container's ports are private to the stack - reachable by other services, but not from your laptop's browser. `ports:` publishes a port out to your host machine, exactly like `-p` on `docker run`.

```yaml
  web:
    ports:
      - "8080:80"
```

*What just happened:* This maps **host** port `8080` to the **container's** port `80`. The format is always `"HOST:CONTAINER"`. So `http://localhost:8080` on your machine reaches nginx listening on port 80 inside the container.

📝 **Which port goes where.** Left of the colon is *your machine*; right of the colon is *inside the container*. `"8080:80"` means "I'll visit 8080, it arrives at 80." Mixing these up is the single most common Compose port mistake.

💡 **Key point - you don't publish what stays internal.** Notice `db` and `cache` have *no* `ports:` block - the API reaches them inside the stack's private network without any port being exposed to your laptop. Only publish a port when something *outside* the stack (your browser, a tool) needs in.

## `environment:` - configuration the container reads at startup

The `environment:` map sets environment variables inside the container. This is the standard way to configure both off-the-shelf images and your own code - connection strings, passwords, feature flags, modes.

```yaml
  db:
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: shop

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

*What just happened:* The official `postgres` image reads `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` on first start and creates that user and database for you. Your `api` reads `DATABASE_URL` and `REDIS_URL` to know where to connect. Look closely at `DATABASE_URL`: the host part is `db`, the *service name* from this file - that's how the API finds the database (Phase 3 explains why that works).

⚠️ **Gotcha - secrets in plain text.** Writing `POSTGRES_PASSWORD: secret` straight into the file is fine for local development, but this file usually lives in Git. Don't commit real production passwords this way. The common fix is to read them from a `.env` file or the host environment instead - covered in depth in [Environment Variables & Config](/guides/env-vars-and-config).

## `depends_on:` - start order, and *only* start order

`depends_on:` tells Compose which services must be *started* before this one. It controls the order Compose launches containers.

```yaml
  api:
    depends_on:
      - db
      - cache
```

*What just happened:* Compose starts `db` and `cache` before it starts `api`, so the things the API depends on are already running by the time it comes up.

⚠️ **Gotcha - "started" is not "ready."** This is the trap that catches everyone, so we'll name it now and fix it properly in Phase 3: `depends_on` waits for the database container to *start*, not for Postgres inside it to be *ready to accept connections*. A database can take a few seconds to initialize after its container starts. So your API can launch, immediately try to connect, and crash - even though `depends_on` did exactly what it promised. Hold that thought; [Phase 3](03-networking-volumes-and-dev-workflow.md) shows the real fix (healthchecks).

## `volumes:` - where your data lives when the container doesn't

Containers are disposable - delete one and everything inside its filesystem is gone. That's fine for stateless services, and a disaster for a database. A **named volume** is storage that lives *outside* any single container, managed by Docker, so your data survives the container being recreated.

📝 **Named volume.** A chunk of disk Docker manages on your behalf, given a name. You mount it into a container at a path; whatever's written there is kept even after the container is destroyed.

There are two parts to it. First, you *declare* the volume at the bottom of the file:

```yaml
volumes:
  db_data:
```

Then you *mount* it into the service that needs it:

```yaml
  db:
    volumes:
      - db_data:/var/lib/postgresql/data
```

*What just happened:* You told Docker "keep a named volume called `db_data`," then mounted it at `/var/lib/postgresql/data` - the path where Postgres stores its data. Now the database's files live in the volume, not in the container. Recreate the `db` container and the data is still there, waiting. We'll come back to *why* this matters and how to verify it in Phase 3.

## Bringing it up

With the file written, the whole stack starts with one command. The first time, Compose pulls images and builds yours, so there's a lot of output; here's the shape of it:

```console
$ docker compose up
[+] Running 5/5
 ✔ Network shop_default    Created
 ✔ Container shop-db-1      Created
 ✔ Container shop-cache-1   Created
 ✔ Container shop-api-1     Created
 ✔ Container shop-web-1     Created
Attaching to api-1, cache-1, db-1, web-1
db-1     | PostgreSQL init process complete; ready for start up.
db-1     | database system is ready to accept connections
cache-1  | Ready to accept connections tcp
api-1    | Server listening on http://0.0.0.0:3000
web-1    | start worker processes
```

*What just happened:* Compose created a private network for the stack, created and started all four containers in dependency order (`db` and `cache` before `api`, `api` before `web`), then *attached* to them - the logs from every service now stream into your terminal, each line prefixed with which service it came from. Visit `http://localhost:8080` and nginx answers.

💡 **Run it in the background.** Add `-d` (detached) and Compose starts the stack and hands your terminal back instead of streaming logs:

```console
$ docker compose up -d
[+] Running 5/5
 ✔ Network shop_default    Created
 ✔ Container shop-db-1      Started
 ✔ Container shop-cache-1   Started
 ✔ Container shop-api-1     Started
 ✔ Container shop-web-1     Started
```

*What just happened:* Same stack, same order, but Compose returned control to you immediately. The containers keep running in the background. This is how you'll usually run it once you trust it.

## Watching the logs

Running detached, you still want to see what your services are saying. `docker compose logs` shows you:

```console
$ docker compose logs -f api
api-1  | Server listening on http://0.0.0.0:3000
api-1  | GET /health 200 4ms
api-1  | GET /api/products 200 31ms
```

*What just happened:* `logs` pulls the output of your services; naming `api` narrows it to just that one, and `-f` ("follow") keeps the stream live, printing new lines as they happen - the same view you'd get from `up` without `-d`, but for one service and on demand. Drop the service name to see all of them interleaved.

## Tearing it down

When you're done, one command removes the whole stack - every container and the network - cleanly:

```console
$ docker compose down
[+] Running 5/5
 ✔ Container shop-web-1     Removed
 ✔ Container shop-api-1     Removed
 ✔ Container shop-cache-1   Removed
 ✔ Container shop-db-1      Removed
 ✔ Network shop_default    Removed
```

*What just happened:* Compose stopped and removed all four containers and deleted the network it created - the exact reverse of `up`. No leftover containers, no orphaned network, nothing to clean up by hand.

⚠️ **Gotcha - `down` keeps your data, on purpose.** Notice the volume isn't in that list. Plain `docker compose down` removes containers and the network but *leaves named volumes alone*, so your database survives a teardown. That's the safe default. If you genuinely want to wipe the data too, you have to ask for it explicitly with `docker compose down -v` - and that `-v` deletes your volumes for real, so treat it with the respect you'd give `rm -rf`.

## Recap

1. **`services:`** - one block per container; the service name is also its network address.
2. **`image:` vs `build:`** - pull a ready-made image, or build your own from a `Dockerfile`. Pin versions.
3. **`ports:`** - `"HOST:CONTAINER"`, only for what needs reaching from outside the stack.
4. **`environment:`** - configuration variables; how services learn each other's addresses and credentials.
5. **`depends_on:`** - start order only - *started*, not *ready* (Phase 3 fixes this).
6. **`volumes:`** - named volumes keep data alive across container recreation; declared at the bottom, mounted into the service.
7. **`up` / `up -d` / `logs -f` / `down`** - bring the stack up (foreground or background), watch it, tear it down without leftovers.

You can now write and run a stack. Next we'll look under the hood at the three things that make a stack actually *work*: how services find each other, how data persists, and how to wire it for fast day-to-day development without setting traps for production.


---

# 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.

```mermaid
flowchart LR
  subgraph Net["Compose-created private network: shop_default - a service's NAME is its hostname"]
    web --> api
    api -- "db:5432" --> db
    api -- "cache:6379" --> cache
  end
```

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

```yaml
  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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```yaml
  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:

```console
$ 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*:

```yaml
  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:

```text
   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.

**Related:** [Docker Without the Magic](/guides/docker-without-the-magic) · [Environment Variables & Config](/guides/env-vars-and-config)
