DevBolt
·11 min read

Docker Compose: A Practical Guide to Multi-Container Apps

DockerDevOpsContainers

Docker Compose lets you define and run multi-container applications with a single YAML file. Instead of running multiple docker run commands with dozens of flags, you describe your entire stack declaratively and bring it up with one command.

Your First Compose File

A compose.yaml file defines services (containers), networks, and volumes:

compose.yaml
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html

  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Run it with docker compose up and you have an Nginx web server, a Node.js API, and a PostgreSQL database — all networked together automatically.

Essential Commands

Terminal
# Start all services (foreground)
docker compose up

# Start in background (detached)
docker compose up -d

# Stop all services
docker compose down

# Stop and remove volumes (clean slate)
docker compose down -v

# Rebuild images before starting
docker compose up --build

# View logs
docker compose logs
docker compose logs api        # specific service
docker compose logs -f         # follow (live)

# Run a one-off command in a service
docker compose exec db psql -U user -d myapp
docker compose run api npm test

# Scale a service
docker compose up -d --scale worker=3

# Show running services
docker compose ps

Key Concepts

Services

Each service becomes a container. You can use a pre-built image or build from a Dockerfile:

compose.yaml
services:
  # Pre-built image from Docker Hub
  redis:
    image: redis:7-alpine

  # Build from a Dockerfile
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
      args:
        NODE_ENV: production

Ports

Map host ports to container ports with HOST:CONTAINER:

compose.yaml
ports:
  - "3000:3000"         # same port
  - "8080:80"           # host 8080 → container 80
  - "127.0.0.1:9090:80" # bind to localhost only

Volumes

Persist data and share files between host and container:

compose.yaml
services:
  api:
    volumes:
      # Bind mount: host path → container path
      - ./src:/app/src

      # Named volume: managed by Docker
      - node_modules:/app/node_modules

  db:
    volumes:
      # Named volume for database persistence
      - pgdata:/var/lib/postgresql/data

# Declare named volumes
volumes:
  pgdata:
  node_modules:

Named volumes survive docker compose down. Bind mounts are great for development (live code reloading) but shouldn't be used in production.

Environment Variables

compose.yaml
services:
  api:
    # Inline
    environment:
      - NODE_ENV=production
      - API_KEY=secret123

    # From a file
    env_file:
      - .env
      - .env.local

Use env_file for sensitive values — don't commit .env files to git. See our .gitignore guide for what to exclude.

depends_on

Control startup order. The basic form waits for the container to start, but not for it to be "ready":

compose.yaml
services:
  api:
    depends_on:
      # Basic: just wait for container to start
      - redis

      # With health check: wait until healthy
      db:
        condition: service_healthy

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5

Always use condition: service_healthy for databases. A started PostgreSQL container isn't ready to accept connections for several seconds.

Networks

By default, all services in a Compose file share a network and can reach each other by service name. You can define separate networks for isolation:

compose.yaml
services:
  web:
    networks:
      - frontend

  api:
    networks:
      - frontend
      - backend

  db:
    networks:
      - backend    # not reachable from web

networks:
  frontend:
  backend:

Common Stack Templates

Node.js + PostgreSQL + Redis

compose.yaml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - ./src:/app/src  # hot reload in dev

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru

volumes:
  pgdata:

WordPress + MySQL

compose.yaml
services:
  wordpress:
    image: wordpress:latest
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wp
      WORDPRESS_DB_PASSWORD: secret
      WORDPRESS_DB_NAME: wordpress
    volumes:
      - wp_data:/var/www/html
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: rootsecret
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wp
      MYSQL_PASSWORD: secret
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  wp_data:
  db_data:

Development vs Production

Use override files to separate dev and prod configuration:

compose.yaml (base)
services:
  api:
    build: .
    environment:
      - NODE_ENV=production
compose.override.yaml (dev — auto-loaded)
services:
  api:
    build:
      target: development
    volumes:
      - ./src:/app/src
    environment:
      - NODE_ENV=development
      - DEBUG=true
    ports:
      - "9229:9229"  # Node.js debugger

Docker Compose automatically merges compose.override.yaml on top of compose.yaml. For production, use an explicit file: docker compose -f compose.yaml -f compose.prod.yaml up.

Resource Limits

Prevent any service from consuming all host resources:

compose.yaml
services:
  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M

  worker:
    deploy:
      replicas: 3  # run 3 instances
      restart_policy:
        condition: on-failure
        max_attempts: 3

Common Mistakes

  • Assuming depends_on means "ready." Without a health check, depends_on only waits for the container to start. Your database might not be accepting connections yet. Always add healthcheck and condition: service_healthy.
  • Bind-mounting node_modules. Mounting ./:/app overwrites the container's node_modules with your host's (or empty) directory. Use a named volume for node_modules to prevent this.
  • Hardcoding secrets in compose files. Use env_file and add .env to .gitignore. For production, use Docker secrets or external secret managers.
  • Using latest tag in production. Always pin image versions (postgres:16 not postgres:latest). The latest tag can change at any time and break your stack.
  • Forgetting to declare volumes. Named volumes must be declared in the top-level volumes: section, not just referenced in services. Docker Compose may create anonymous volumes instead, which are easily lost.

Skip the server management

Need managed PostgreSQL, MySQL, or Redis alongside your containers? DigitalOcean Managed Databases handle backups, failover, and scaling so you can focus on your app. Pair with App Platform for zero-ops container deployment.

docker-compose.yml vs compose.yaml

The modern filename is compose.yaml (recommended by Docker). The old docker-compose.yml still works but is considered legacy. Similarly, use docker compose (with a space) instead of docker-compose (with a hyphen) — the latter is the standalone v1 binary which is no longer maintained.

Try It Yourself

Use our Docker Compose Validator to validate your compose files for syntax errors, missing dependencies, and best practices. Building a Dockerfile? The Dockerfile Validator checks for security issues, layer optimization, and common mistakes. And for validating your environment variables, try the .env File Validator.