Modern applications are never one process — app plus Postgres plus Redis plus a worker, each with versions and env config. Docker Compose turns that entire stack into one declarative file and one command: docker compose up. Everyone's local environment becomes identical, onboarding shrinks from a day of setup docs to installing Docker, and "works on my machine" finally dies. Here's the working subset of Compose that covers real daily use.
The anatomy of a working compose.yaml#
services:
app:
build: . # build from local Dockerfile
ports:
- "3000:3000" # host:container
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
volumes:
- ./src:/app/src # bind mount for hot reload
command: npm run dev
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data # named volume persists data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
cache:
image: redis:7-alpine
volumes:
pgdata:
docker compose up starts all three; down stops them. That file is your environment documentation.
Networking: the part that surprises everyone#
Compose creates a private network where services reach each other by service name — that's why DATABASE_URL says host db, not localhost. Inside the app container, localhost means the container itself, not your machine or its neighbors. This single fact prevents the most common beginner hour-lost-debugging session:
- App → database:
postgres://...@db:5432/... - Your browser → app:
localhost:3000(via the published port) - Service-to-service: always the service name as hostname
Volumes: two kinds, two jobs#
Bind mounts (./src:/app/src) map host folders into containers — the mechanism behind live reload: edit code locally, the running dev server sees it instantly. Never bake development source into images (images are immutable templates).
Named volumes (pgdata:/var/lib/postgresql/data) are Docker-managed persistent storage — how your database survives docker compose down. The lifecycle rules worth tattooing somewhere visible:
downkeeps named volumes (data safe)down -vdeletes them (data gone — sometimes exactly what you want for a fresh slate)
Dependencies and startup ordering#
depends_on alone only controls start order, not readiness — Postgres accepting connections takes seconds after its process starts. Hence the condition: service_healthy pattern above with a healthcheck: app waits until the database actually answers. Without it, first-run apps crash-loop while their DB boots, and everyone learns this lesson the hard way once.
The daily command set#
docker compose up -d # start detached
docker compose logs -f app # follow one service's logs
docker compose exec db psql -U postgres # shell into a service
docker compose up --build # rebuild after Dockerfile changes
docker compose down # stop everything (data persists)
Debugging workflow mirrors single-container Docker: check logs first, exec in second, rebuild third.
Production? Mostly no — but not entirely#
Compose is built for local development and simple single-host deployments. Real production orchestration wants restart policies across machines, rolling updates, secret management — Kubernetes territory. But two production-adjacent uses keep Compose relevant everywhere:
- CI pipelines spin up app + database via Compose to run integration tests against real services
- The compose file doubles as executable architecture documentation — new engineers read it to understand system topology even if they never run it
Interview-ready summary#
Compose = declarative multi-container environments: services defined in YAML, networked by name, persisted with named volumes, ordered by healthchecks. One command up, one command down, identical on every machine. Know the localhost-vs-service-name networking trap and the down -v data cliff, and you're past most Compose questions anyone asks.
Related: Docker foundations · Kubernetes next steps · system design framework