5. Orchestrating with Docker Compose

Real applications have multiple services. Docker Compose lets you define them in one YAML file and start the whole stack with a single command.

What Docker Compose is

Compose is a YAML-driven wrapper around docker run that describes a stack of services in one file. For the news aggregator, that stack is three containers: the FastAPI app, Postgres, and Redis. The previous page got the API into one image; this page gets the rest of the dependencies orchestrated alongside it, including the network they share and the secrets they read from .env.

The pain Compose removes

Imagine starting your News API stack manually without Docker Compose. You'd run these commands in separate terminal windows:

Terminal (PostgreSQL)
docker run --name postgres-db \
  -e POSTGRES_USER=newsapi \
  -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
  -e POSTGRES_DB=news_aggregator \
  -p 5432:5432 \
  -v postgres_data:/var/lib/postgresql/data \
  postgres:15
Terminal (Redis)
docker run --name redis-cache \
  -p 6379:6379 \
  redis:7-alpine
Terminal (FastAPI)
docker run --name news-api \
  --link postgres-db:postgres \
  --link redis-cache:redis \
  -e DATABASE_URL=postgresql://newsapi:$POSTGRES_PASSWORD@postgres:5432/news_aggregator \
  -e REDIS_URL=redis://redis:6379 \
  -e NEWSAPI_KEY=your_key_here \
  -e GUARDIAN_KEY=your_key_here \
  -p 8000:8000 \
  news-api:optimized

This workflow is tedious and error-prone. You must remember the exact commands, type them correctly, manage three terminal windows, and stop services individually when done. If you restart your computer, you repeat everything. Teammates need documentation explaining every flag and environment variable. This doesn't scale.

Docker Compose replaces these three commands with one: docker compose up. All configuration lives in docker-compose.yml, a version-controlled file that documents your entire stack. Teammates clone your repository and run one command. Everything works.

Your first docker-compose.yml

Make: Create a docker-compose.yml file in your project root. This file defines all three services (API, PostgreSQL, Redis) and their configuration:

docker-compose.yml
version: '3.8'

services:
  # PostgreSQL Database
  postgres:
    image: postgres:15-alpine
    container_name: news-postgres
    environment:
      POSTGRES_USER: newsapi
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: news_aggregator
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U newsapi"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - news-network

  # Redis Cache
  redis:
    image: redis:7-alpine
    container_name: news-redis
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - news-network

  # News API Application
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: news-api
    environment:
      DATABASE_URL: postgresql://newsapi:${POSTGRES_PASSWORD}@postgres:5432/news_aggregator
      REDIS_URL: redis://redis:6379
      NEWSAPI_KEY: ${NEWSAPI_KEY}
      GUARDIAN_KEY: ${GUARDIAN_KEY}
    ports:
      - "8000:8000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - news-network
    restart: unless-stopped

# Named volumes for persistent data
volumes:
  postgres_data:
  redis_data:

# Custom network for service communication
networks:
  news-network:
    driver: bridge

Check: Before starting the stack, create a .env file for sensitive credentials. Docker Compose loads these automatically:

.env
POSTGRES_PASSWORD=your_secure_password_here
NEWSAPI_KEY=your_newsapi_key_here
GUARDIAN_KEY=your_guardian_api_key_here

Now start your entire stack with one command:

Terminal
docker compose up

Docker Compose builds your API image (if not already built), pulls PostgreSQL and Redis images, creates containers, and starts all three services. You'll see logs from all services in one terminal:

Terminal
[+] Running 4/4
 ✔ Network news-network      Created
 ✔ Container news-postgres   Started
 ✔ Container news-redis      Started
 ✔ Container news-api        Started

news-postgres  | PostgreSQL init process complete; ready for start up.
news-redis     | Ready to accept connections
news-api       | INFO:     Started server process
news-api       | INFO:     Waiting for application startup.
news-api       | INFO:     Application startup complete.
news-api       | INFO:     Uvicorn running on http://0.0.0.0:8000

Extract: Your entire stack is running. Open http://localhost:8000/docs in your browser to verify the API works. PostgreSQL stores articles. Redis will cache them (after you implement caching in Section 6). Everything runs in isolated containers but communicates through the news-network bridge network.

version: '3.8': Specifies Docker Compose file format version. Version 3.8 supports all features we need (health checks, depends_on conditions).

services: Each service is a container. postgres, redis, and api are service names used for internal networking. Your API connects to postgres:5432 and redis:6379 using these service names as hostnames.

environment: Environment variables passed to containers. ${POSTGRES_PASSWORD} references the value from your .env file, preventing hardcoded secrets in version control.

volumes: Named volumes provide persistent storage. postgres_data:/var/lib/postgresql/data stores database files outside the container. When you stop containers, data persists. When you restart, data is intact.

healthcheck: Docker monitors service health. pg_isready checks if PostgreSQL accepts connections. The API waits until PostgreSQL is healthy before starting, preventing connection errors.

depends_on with condition: The api service depends on postgres and redis being healthy. Docker starts services in order and waits for health checks to pass. This prevents the API from trying to connect to databases that aren't ready yet.

networks: All services join news-network. This provides DNS resolution (services reach each other by name) and network isolation (other containers can't access your services unless explicitly connected to this network).

Redis persistence: The --appendonly yes flag enables AOF (Append Only File) persistence, writing every cache operation to disk. For pure caching scenarios, this durability is optional since an empty cache after restart just means "Cache Miss" and fresh data gets fetched. You can disable it (command: redis-server) for higher write performance if you're comfortable with an empty cache after restarts. For production, AOF provides safety at the cost of I/O overhead.

Inside a container, localhost refers to that container itself, not your host machine. If your API tries to connect to localhost:5432, it's looking for PostgreSQL inside the API container. It won't find it.

This is the #1 confusion point when moving to Docker. You must use service names (postgres, redis) as hostnames. Docker's internal DNS automatically resolves these names to the correct container IP addresses on the news-network.

Wrong: DATABASE_URL=postgresql://user:pass@localhost:5432/db
Right: DATABASE_URL=postgresql://user:pass@postgres:5432/db

When accessing from your host machine (browser, curl), you still use localhost:8000 because port mapping exposes container ports to your host. But container-to-container communication uses service names, never localhost.

Diagram showing the localhost perspective in Docker. Left side shows Your Laptop (Host) with a browser connecting to localhost:8000 successfully (green checkmark) reaching the API Container in the Docker Network. Inside the Docker Network, the API Container incorrectly tries to connect to localhost:5432 (red X with curved arrow) looking for PostgreSQL within itself. The correct approach shows the API Container connecting to 'host: postgres' (green checkmark) which successfully reaches the Postgres Container.
From your host machine, use localhost:8000 to reach containers. Inside containers, use service names like "postgres" for container-to-container communication.

Environment variables and secrets

Your docker-compose.yml references environment variables using ${VARIABLE_NAME} syntax. This pattern separates configuration from code, preventing secrets from appearing in version control. The next two snippets show the mistake first, then the safer shape.

Bad practice: Hardcoding secrets in docker-compose.yml:

docker-compose.yml (anti-pattern)
environment:
  POSTGRES_PASSWORD: MySecretPassword123
  NEWSAPI_KEY: abc123def456ghi789

You commit this file to git. Everyone who clones your repository sees your passwords and API keys. If your repository is public, anyone on the internet sees them. Attackers scan GitHub for exposed credentials and exploit them within hours.

Good practice: Reference environment variables from .env file:

docker-compose.yml
environment:
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
  NEWSAPI_KEY: ${NEWSAPI_KEY}

Your .gitignore includes .env, so secrets never reach git. Instead, provide a template file:

.env.example (commit this to git)
# Copy this file to .env and fill in your actual values
POSTGRES_PASSWORD=changeme
NEWSAPI_KEY=get_from_newsapi.org
GUARDIAN_KEY=get_from_theguardian.com

Teammates copy .env.example to .env and add their credentials. Each developer has their own .env file that never gets committed. Production environments use different credentials stored in secure secret management systems (AWS Secrets Manager, environment variables in deployment platforms).

Why this matters beyond security: Different environments need different values. Development uses localhost databases. Production uses AWS RDS. Your NewsAPI key has different rate limits than your teammate's key. Environment variables let the same docker-compose.yml work everywhere by changing only the .env file.

The principle: Configuration that changes between environments (URLs, credentials, feature flags) lives in environment variables. Configuration that's the same everywhere (service names, ports inside containers) lives in docker-compose.yml.

In production: You won't use .env files. Cloud platforms provide secure secret storage (AWS Secrets Manager, Kubernetes Secrets). But the pattern remains: secrets come from the environment, never hardcoded in configuration files.

Managing the stack

Docker Compose provides commands for managing your multi-container application throughout development. These commands control the entire stack together rather than managing containers individually.

Start the stack:

Terminal
docker compose up

This builds images if needed, creates containers, and starts all services. Logs from all containers appear in your terminal. Use -d flag for detached mode (runs in background):

Terminal
docker compose up -d

Stop the stack:

Terminal
docker compose down

This stops all containers and removes them. Named volumes (database data) persist. If you want to delete volumes too (starting fresh):

Terminal
docker compose down -v

View logs:

Terminal
docker compose logs

For live logs (follows new output): docker compose logs -f. For logs from specific service: docker compose logs api.

Rebuild after code changes:

Terminal
docker compose up --build

The --build flag forces rebuilding images even if they already exist. Use this after changing application code or dependencies.

Execute commands inside containers:

Terminal
docker compose exec api python -c "import os; print(os.getenv('DATABASE_URL'))"
docker compose exec postgres psql -U newsapi -d news_aggregator

This runs commands inside running containers. The first command checks what database URL the API container sees; the second opens a PostgreSQL shell inside the database container so you can inspect data directly.

The stack now has Postgres for durable article storage and Redis sitting empty alongside it. Next, in section 6, we wire Redis in front of the endpoints that profiling identified as slow and re-measure against the baseline.

Checkpoint: Docker Compose

Use this quiz to check your understanding. Try to answer each question out loud or in a notebook before expanding the explanation. If you get stuck, that's a signal to revisit the relevant section.

Select each question to reveal a detailed answer:
What problem does Docker Compose solve that manual docker run commands don't?

Answer: Docker Compose orchestrates multi-container applications, eliminating the need to manually manage multiple containers with complex commands. Without Compose, you'd need three terminal windows, three long commands with networking flags, and manual coordination of startup order.

Docker Compose defines your entire stack in one YAML file, starts everything with docker compose up, manages networking automatically, and ensures services start in the correct order using health checks. Configuration is version-controlled and documented, so teammates run one command to start the full stack.

Why store secrets in .env files instead of hardcoding them in docker-compose.yml?

Answer: Security and flexibility. docker-compose.yml is committed to version control. If you hardcode secrets, they're visible to everyone who clones your repository. If your repository is public, attackers find and exploit credentials within hours.

.env files stay local (excluded by .gitignore), so secrets never reach git. Different environments use different credentials: development uses local databases, production uses AWS RDS. The same docker-compose.yml works everywhere by changing only the .env file.

Provide .env.example as a template showing required variables without exposing real values.