Docker Compose Complete Guide 2025 — Multi-Container Apps Made Simple

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

Running a modern web application locally requires more than just your app process. A typical stack includes the application server, a relational database, a cache like Redis, a message queue, and perhaps a search engine. Manually starting, networking, and configuring all these services is error-prone and hard to document.

Docker Compose solves this by describing your entire stack in a single YAML file that every team member can run with one command: docker compose up. It eliminates "it works on my machine" for entire development environments, not just individual containers. CI pipelines use Docker Compose to spin up integration test environments in seconds.

In 2025, Docker Compose v2 is bundled with Docker Desktop and is the standard for local development across teams of all sizes. Learning Compose is also a gentle introduction to the declarative configuration concepts behind Kubernetes manifests.

Docker Compose File Structure

# docker-compose.yml
version: '3.8'  # Compose file format version
 
services:        # Container definitions
  api:           # Service name (also DNS hostname on the network)
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
    depends_on:
      - db
 
  db:
    image: postgres:16-alpine
 
volumes:         # Named volumes (persist data across restarts)
  pgdata:
 
networks:        # Custom networks (optional; Compose creates one by default)
  backend:

Complete Real-World Example

A Node.js API with PostgreSQL, Redis, and Nginx reverse proxy:

version: '3.8'
 
services:
  nginx:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - api
 
  api:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://app:secret@db:5432/appdb
      - REDIS_URL=redis://redis:6379
    volumes:
      - ./src:/app/src
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
 
  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
 
  worker:
    build: .
    command: node worker.js
    environment:
      - DATABASE_URL=postgres://app:secret@db:5432/appdb
      - REDIS_URL=redis://redis:6379
    depends_on:
      - db
      - redis
    restart: unless-stopped
 
volumes:
  pgdata:
  redis_data:

Essential Docker Compose Commands

# Start all services in background
docker compose up -d
 
# Start and rebuild images before starting
docker compose up -d --build
 
# Stop services (keep containers and volumes)
docker compose stop
 
# Stop and remove containers (keep volumes)
docker compose down
 
# Stop and remove containers AND volumes
docker compose down -v
 
# View logs from all services
docker compose logs -f
 
# View logs from a specific service
docker compose logs -f api
 
# Scale a service
docker compose up -d --scale api=3
 
# Execute a command in a running service
docker compose exec api /bin/sh
docker compose exec db psql -U app -d appdb
 
# Restart a single service
docker compose restart api
 
# View service status
docker compose ps

Environment Variables and .env Files

Docker Compose automatically loads a .env file from the project directory:

# .env (do not commit this file)
POSTGRES_USER=app
POSTGRES_PASSWORD=super_secret
POSTGRES_DB=appdb
API_PORT=3000
NODE_ENV=development

Reference these variables in docker-compose.yml:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
 
  api:
    ports:
      - "${API_PORT}:3000"
    environment:
      - NODE_ENV=${NODE_ENV}

For different environments, layer multiple Compose files:

# docker-compose.override.yml is auto-merged with docker-compose.yml in dev
# For production:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Health Checks and Startup Dependencies

depends_on alone only waits for the container to start, not for the service inside it to be ready. Always pair with health checks for databases:

services:
  api:
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

Networking in Docker Compose

All services share a default bridge network and resolve each other by service name:

services:
  api:
    environment:
      # 'db' and 'redis' are hostnames — no IPs needed
      - DATABASE_URL=postgres://app:pass@db:5432/mydb
      - REDIS_URL=redis://redis:6379

For network isolation between service tiers:

services:
  nginx:
    networks: [frontend]
 
  api:
    networks: [frontend, backend]
 
  db:
    networks: [backend]  # not accessible directly from nginx
 
networks:
  frontend:
  backend:

Compose for CI/CD Integration Tests

# docker-compose.test.yml
version: '3.8'
services:
  test:
    build:
      context: .
      target: test
    environment:
      - DATABASE_URL=postgres://test:test@db:5432/testdb
    depends_on:
      db:
        condition: service_healthy
    command: npm test
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: testdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test"]
      interval: 5s
      retries: 10
# In GitHub Actions or GitLab CI
docker compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from test
docker compose -f docker-compose.test.yml down -v

Common Mistakes

  • Using depends_on without health check conditions — the service crashes because the database is not ready
  • Hardcoding passwords in docker-compose.yml instead of using .env or secrets
  • Not cleaning up volumes after CI test runs (down -v) — stale data causes flaky tests
  • Mounting the entire project directory into containers — this exposes .env and .git inside the container
  • Not using restart: unless-stopped for long-running dev services that crash on startup errors

Best Practices

  • Commit a .env.example to Git with placeholder values; add .env to .gitignore
  • Use service_healthy conditions for all database-dependent services
  • Create docker-compose.override.yml for dev-specific settings (hot reload volumes, debug ports)
  • Tag your images with specific versions or commit SHAs, never latest
  • Use Compose profiles to group optional services (monitoring, mail catcher) that are not always needed
  • Keep the base docker-compose.yml lean and layer environment differences with override files

Key Takeaways

  • Docker Compose defines entire multi-container stacks in one YAML file, enabling reproducible one-command environments
  • All services share a default network and resolve each other by service name — no manual IP configuration needed
  • depends_on with condition: service_healthy prevents race conditions; plain depends_on is not enough for databases
  • .env files supply environment variables automatically — never commit secrets to version control
  • docker compose down -v removes named volumes; use only when you need a clean database state
  • Override files (-f docker-compose.prod.yml) let you layer dev, test, and production configs cleanly
  • Compose is the standard CI pattern for integration tests: start dependencies, run tests, capture exit code, tear down
  • Named volumes persist across container restarts; removing the container does not remove the volume data

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading