/user/kayd @ devops :~$ cat docker-compose-multi-container-guide.md

Docker Compose: Multi-Container Apps Made Simple Docker Compose: Multi-Container Apps Made Simple

QR Code linking to: Docker Compose: Multi-Container Apps Made Simple
Karandeep Singh
Karandeep Singh
• 5 minutes

Summary

A hands-on Docker Compose guide — go from running containers by hand to defining a web + Postgres + Redis stack in one file, with service networking, volumes, environment variables, and the commands you use every day.

Running one container with docker run is easy. Running a web app plus its database plus a cache — each needing the right flags, network, and startup order — turns into a wall of fragile shell commands. Docker Compose fixes that by describing your whole stack in a single YAML file you can start with one command.

This Docker Compose guide is hands-on. You will feel the pain of wiring containers together by hand, then replace it with a compose.yaml file that defines a web service, Postgres, and Redis — with networking, persistent storage, and environment variables handled for you.

Docker Compose — define a multi-container app stack in one file

The Problem Compose Solves

Say your app needs three containers. By hand, that looks like this:

docker network create appnet
docker run -d --name db --network appnet -e POSTGRES_PASSWORD=secret postgres:16
docker run -d --name cache --network appnet redis:7
docker run -d --name web --network appnet -p 8080:8080 -e DATABASE_URL=... myapp

Every restart means retyping (or scripting) all of it. There’s no single source of truth, teammates set it up differently, and tearing it down cleanly is its own chore. Compose replaces all of that with one declarative file.

Your First compose.yaml

Create a file named compose.yaml in your project root. This defines the same three-container stack declaratively:

services:
  web:
    build: .                       # build from the local Dockerfile
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=postgres://app:secret@db:5432/app
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache
  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=app
    volumes:
      - pgdata:/var/lib/postgresql/data
  cache:
    image: redis:7

volumes:
  pgdata:

Start the entire stack with one command:

docker compose up -d        # -d runs in the background (detached)
docker compose ps           # see what's running
docker compose logs -f web  # follow one service's logs

That’s the whole value proposition: three coordinated containers, one file, one command.

How Services Talk to Each Other

Notice the DATABASE_URL points at db:5432, not an IP. Compose creates a shared network for the project and registers each service name as a DNS hostname. So web reaches Postgres at db and Redis at cache — automatically.

    graph LR
  subgraph Compose network
    W[web :8080] --> D[(db - postgres :5432)]
    W --> C[(cache - redis :6379)]
  end
  U[Your browser] -->|localhost:8080| W
  

This is why you should never hard-code container IPs. Service names are stable; IPs are not.

Persisting Data with Volumes

Containers are ephemeral — delete one and its filesystem is gone. That’s fine for the web service but catastrophic for the database. The pgdata named volume mounted at Postgres’s data directory keeps your data across restarts and rebuilds.

docker compose down          # stops containers, KEEPS the volume (data safe)
docker compose up -d         # data is still there

Environment Variables and the .env File

Hard-coding secret in the file is fine for a demo, but real projects pull config from a .env file that Compose reads automatically:

# .env (do NOT commit this)
POSTGRES_PASSWORD=supersecret
  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}

Add .env to your .gitignore. Commit a .env.example with blank values so teammates know what to set.

Startup Order Is Not Readiness

depends_on controls start order, but here’s the trap that bites everyone: it does not wait for a dependency to be ready. Postgres can be “started” yet still be initializing and refusing connections, so your web app crashes on boot. Fix it with a healthcheck:

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 5
  web:
    build: .
    depends_on:
      db:
        condition: service_healthy   # now web waits until db actually accepts connections

The Commands You’ll Use Daily

docker compose up -d --build     # rebuild images and (re)start everything
docker compose ps                # list services + status + ports
docker compose logs -f           # tail logs from all services
docker compose exec db psql -U app   # open a shell/CLI inside a running service
docker compose restart web       # restart a single service
docker compose stop              # stop without removing
docker compose down              # stop and remove containers + network
docker compose down -v           # ...and delete volumes (data)
Compose automatically merges `compose.yaml` with `compose.override.yaml` if present. Keep shared config in the base file and developer-only tweaks (bind-mounts for live reload, exposed debug ports) in the override: ```yaml # compose.override.yaml — local development only services: web: volumes: - .:/app # live-reload your source code environment: - DEBUG=true ``` For production, run an explicit file instead: `docker compose -f compose.yaml -f compose.prod.yaml up -d`.

Common Docker Compose Pitfalls

  • Scaling a service with a fixed host port. docker compose up --scale web=3 fails if web maps "8080:8080", because three containers can’t share one host port. Use a range or put a load balancer in front.
  • Committing secrets. The .env file and inline passwords end up in Git history. Use .env (ignored) and .env.example (committed).
  • Assuming depends_on waits for readiness. It doesn’t — add healthchecks (see above).
  • Losing data to down -v. Only use -v when you intend to wipe volumes.
  • Editing the wrong file. With override files, remember Compose merges them; check docker compose config to see the final, resolved configuration.

When to Graduate Beyond Compose

Compose is ideal for local development and small single-host deployments. Once you need self-healing across multiple machines, rolling updates, and autoscaling, that’s the job of an orchestrator — see Kubernetes fundamentals: pods, deployments, and services. If you want to understand what a container actually is under the hood, containers from scratch in Go builds the primitives by hand, and for log handling in containerized apps, NGINX logs and Docker is a practical companion.

Question

What's the most complex multi-container stack you've defined in a single Docker Compose file?

References and Further Reading

Similar Articles

More from devops

Knowledge Quiz

Test your general knowledge with this quick quiz!

A set of multiple-choice questions to test your knowledge.

Take as much time as you need.

Your score will be shown at the end.