Docker Compose for Multi-Container Apps

intermediate
13 min

Docker Compose for Multi-Container Apps

Real applications are rarely one container. A typical web stack is an app server, a database, and a cache - and starting each with a long docker run command, on the right network, in the right order, gets painful fast. Docker Compose solves this: you describe the whole stack in one YAML file and manage it with single commands.

Your First compose.yaml

Create a file named compose.yaml (the older name docker-compose.yml also works):

yaml
services: api: build: . ports: - "3000:3000" environment: DATABASE_URL: postgres://app:s3cret@db:5432/shop REDIS_URL: redis://cache:6379 depends_on: - db - cache db: image: postgres:16 environment: POSTGRES_USER: app POSTGRES_PASSWORD: s3cret POSTGRES_DB: shop volumes: - pgdata:/var/lib/postgresql/data cache: image: redis:7-alpine volumes: pgdata:

Then bring everything up:

bash
docker compose up -d

Compose builds the api image, pulls postgres and redis, creates a dedicated network, starts all three containers, and wires them together. Because every service joins the same network, the api reaches the database simply at hostname db and the cache at cache - service names become DNS names automatically.

The Everyday Commands

bash
docker compose up -d # start (and create) everything docker compose ps # status of this project only docker compose logs -f api # follow logs for one service docker compose exec api sh # shell into a running service docker compose restart api # restart one service docker compose down # stop and remove containers + network docker compose down -v # ...and delete named volumes too

Note that down preserves named volumes by default, so your database survives. Add -v only when you genuinely want a clean slate.

Rebuilding After Code Changes

When the Dockerfile or source changes, rebuild and restart in one step:

bash
docker compose up -d --build

Compose is smart about state: it only recreates containers whose configuration or image actually changed.

Key Sections of the File

  • build vs image: build from a local Dockerfile, or pull a ready-made image.
  • ports: same host:container mapping as docker run -p.
  • environment / env_file: per-service configuration.
  • volumes: named volumes for data, or bind mounts like ./src:/app/src for live-reload development.
  • depends_on: controls startup order. It does not wait for a service to be ready - add a healthcheck for that:
yaml
db: image: postgres:16 healthcheck: test: ["CMD-SHELL", "pg_isready -U app"] interval: 5s retries: 5 api: depends_on: db: condition: service_healthy

Now the api container starts only after Postgres actually accepts connections - the fix for the classic "connection refused on startup" race.

One Project, Isolated

Compose namespaces everything (containers, network, volumes) with a project name derived from the folder. Two different projects on one machine never collide, and docker compose down cleans up only its own resources.

Key Takeaways

  • Compose replaces stacks of docker run commands with one declarative YAML file.
  • Services reach each other by service name thanks to the automatic project network.
  • up -d, logs -f, exec, and down cover the daily workflow.
  • depends_on orders startup; healthchecks with service_healthy guarantee readiness.
  • Named volumes in the file give databases persistence across down and up.

Next up: Dockerizing a Node.js Application, a full end-to-end walkthrough putting Dockerfile, networking, and Compose together.