Multi-Container #

One of Docker Compose’s main strengths is managing many containers as one cohesive unit. When your application has several interrelated services — web frontend, API backend, database, cache, message broker — Compose lets you define, run, and stop all of them with one command.

This article covers common multi-container patterns, how Compose manages networking and inter-service dependencies, and best practices for clean, maintainable stacks.

Multi-Container Architecture Patterns #

There are several architecture patterns commonly found in multi-container applications. Each has different characteristics and use cases.

Three-Tier Architecture #

The most classic pattern: presentation tier (web), application tier (API), data tier (database). Each tier is a separate service.

flowchart LR
    U[User] --> W[Web Frontend]
    W --> A[API Backend]
    A --> D[(Database)]
    A --> C[(Cache)]
services:
  web:
    image: nginx:alpine
    ports:
      - "80:80"
    depends_on:
      - api

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache

  db:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data

  cache:
    image: redis:7-alpine

volumes:
  db-data:

This pattern has clear separation of responsibilities. The web layer scales independently from the API. The database is isolated. The cache is optional.

Microservices #

Large applications are split into many small services, each with a single responsibility. Compose can manage a microservice stack for development, although production usually needs Kubernetes.

flowchart TB
    GW[API Gateway] --> U[user-service]
    GW --> O[order-service]
    GW --> P[product-service]
    GW --> Pay[payment-service]
    U --> UD[(user-db)]
    O --> OD[(order-db)]
    P --> PD[(product-db)]
    Pay --> PayDB[(payment-db)]

For 5+ services, Compose can still handle it, but monitoring, scaling, and deployment need a more advanced orchestrator.

Frontend + Backend #

A common pattern where frontend and backend are deployed separately.

services:
  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    environment:
      - API_URL=http://api:8080

  backend:
    build: ./backend
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/myapp

  db:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

The frontend uses port 3000 (or 5173 for Vite), the backend 8080. Compose automatically creates an internal network so frontend can talk to backend via the hostname api (the service name).

Worker + Queue #

Applications with workers asynchronously processing messages from a queue.

services:
  api:
    build: ./api
    environment:
      - QUEUE_URL=amqp://rabbitmq:5672

  worker:
    build: ./worker
    environment:
      - QUEUE_URL=amqp://rabbitmq:5672
    depends_on:
      - rabbitmq

  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "15672:15672"

The worker and API share the same queue configuration. Scaling the worker is easy: docker compose up -d --scale worker=5.

Inter-Service Networking #

One of the most powerful things about Compose is automatic service discovery on the internal network. Services in the same Compose file can automatically resolve each other via hostname = service name.

services:
  api:
    image: myapi
    environment:
      - DATABASE_URL=postgres://postgres:pass@db:5432/myapp

In the environment variable above, db is a hostname. Compose automatically creates a DNS entry on the internal network so api can resolve db to the database container’s IP.

The Default Network #

If you don’t define explicit networks, Compose creates one default network named like <project>_default. All services automatically join this network.

# View the networks Compose created
docker network ls

# Inspect a network
docker network inspect myapp_default

Multiple Networks #

For more control, define several networks and assign services by role.

services:
  web:
    networks:
      - frontend
      - backend
  
  api:
    networks:
      - frontend
      - backend
  
  db:
    networks:
      - backend

networks:
  frontend:
  backend:

With this configuration, db only exists on the backend network. The web service has interfaces on both networks. The db service can’t be reached from outside backend.

This is a common defense in depth pattern — critical services are separated from the frontend network.

Aliases #

A service can have several hostnames on one network.

services:
  api:
    networks:
      frontend:
        aliases:
          - api-service
          - myapi

The api service can now be reached via api, api-service, or myapi on the frontend network.

External Networks #

You can use networks that already exist in Docker (not created by Compose).

services:
  api:
    networks:
      - shared-network

networks:
  shared-network:
    external: true
    name: my-shared-network

Useful for connecting different Compose stacks or services managed outside Compose (e.g. a managed database).

Dependency Management #

Compose supports two kinds of dependencies: depends_on (startup order) and links (legacy, rarely used).

Depends On: Startup Order #

services:
  web:
    depends_on:
      - api
  
  api:
    depends_on:
      - db

In the example, db starts first, then api, then web. But depends_on only waits for containers to start, not for services to be ready to accept requests.

To truly wait for readiness, add healthchecks and a condition.

services:
  api:
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

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

condition can be:

  • service_started (default) — the container has started.
  • service_healthy — the healthcheck passed.
  • service_completed_successfully — the container exited with code 0.

Long Startup Dependencies #

Some services take a long time to be ready (large databases, JVM warm-up, lazy initialization). Healthcheck + retry is the right combination.

services:
  api:
    depends_on:
      db:
        condition: service_healthy
    restart: on-failure

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s

start_period gives the service 30 seconds without the healthcheck counting as failed — useful for services needing long initialization.

Sharing Data Between Services #

Containers are usually isolated, but sometimes need to share data. Compose supports this via volumes.

Shared Volumes #

services:
  web:
    volumes:
      - uploads:/var/www/uploads
  
  processor:
    volumes:
      - uploads:/var/lib/processor

volumes:
  uploads:

The web uploads files to /var/www/uploads, the processor reads from /var/lib/processor. The uploads volume is shared storage.

Read-Only Mounts #

For containers that only need to read:

services:
  web:
    volumes:
      - configs:/etc/app:ro

:ro makes the mount read-only. The service can only read, not write.

The Init Container Pattern #

Containers that run first to set up data, then exit.

services:
  app:
    volumes:
      - data:/app/data
    depends_on:
      - init-data
  
  init-data:
    image: alpine
    volumes:
      - data:/app/data
    command: sh -c "echo '$(date)' > /app/data/init.txt"
    restart: "no"

The init container runs once, writes data, exits. The app service can use the prepared data.

Service Scaling #

Compose supports scaling services to multiple instances with the --scale flag.

# Scale web to 3 instances
docker compose up -d --scale web=3

# Scale worker to 5 instances
docker compose up -d --scale worker=5

Each instance is a separate container. They share configuration but have separate state (filesystem, process IDs, etc.).

Load Balancing #

For scaled services, you need a load balancer in front. Compose itself has no internal load balancing.

services:
  web:
    build: ./web
    expose:
      - "3000"
  
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - web

expose (not ports) makes the port only accessible on the internal network, not the host. Nginx in front becomes the single entry point.

Scaling has limits. --scale can’t be used for services with a container_name (names must be unique). It’s also incompatible with the ports shorthand (use the long form target: published so each container gets a unique published port).

Environment-Specific Configuration #

For the same setup used in development, staging, and production, use multiple Compose files.

# docker-compose.yml (base)
services:
  web:
    build: .
    environment:
      - NODE_ENV=production

# docker-compose.override.yml (development, auto-loaded)
services:
  web:
    environment:
      - NODE_ENV=development
    volumes:
      - ./src:/app/src  # hot reload
    ports:
      - "3000:3000"

When docker compose up runs in development, base + override are merged automatically. For production, run only the base:

docker compose -f docker-compose.yml up -d

Best Practices #

Separate Configuration per Environment #

Use separate files for development, staging, production. Secret management via secrets: (for Compose on Swarm) or .env (for development).

Use Descriptive Service Names #

Service names become internal hostnames. Choose names that describe their function.

# GOOD
services:
  web:
    ...
  api:
    ...
  db:
    ...
  cache:
    ...

# BAD
services:
  container1:
    ...
  myservice:
    ...
  app:
    ...

Use Healthchecks for Critical Dependencies #

For services with mandatory dependencies that must be ready before dependents start, add healthchecks and condition: service_healthy.

Avoid Hardcoded Secrets #

Use .env files or secrets: (for Swarm). Don’t hardcode passwords in YAML.

Volumes for Persistent Data #

Always. Databases, uploads, important logs. Containers can be recreated without losing data.

Explicit Image Tags #

image: postgres:16-alpine    # GOOD
image: postgres:latest       # BAD - surprise updates

The latest tag can change without you noticing. Always pin versions.

Resource Limits #

To prevent one service from consuming all resources, set limits.

services:
  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
The key to multi-container success: a clear dependency graph, healthchecks for slow-starting services, role-based network isolation, and resource limits so one service doesn’t disturb the others. Start with a small stack, add services one by one, and always test scaling and restart behavior.

A Practical Example: A Complete E-Commerce Stack #

A real e-commerce stack usually has 6-10 services. The example below shows a clean pattern.

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      - web
    networks:
      - frontend
    restart: unless-stopped

  web:
    build: ./web
    environment:
      - API_URL=http://api:8080
    depends_on:
      - api
    networks:
      - frontend
      - backend
    restart: unless-stopped

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgres://shop:pass@db:5432/shop
      - REDIS_URL=redis://cache:6379
      - RABBITMQ_URL=amqp://guest:guest@mq:5672
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
      mq:
        condition: service_healthy
    networks:
      - backend
    restart: unless-stopped

  worker:
    build: ./worker
    environment:
      - DATABASE_URL=postgres://shop:pass@db:5432/shop
      - REDIS_URL=redis://cache:6379
      - RABBITMQ_URL=amqp://guest:guest@mq:5672
    depends_on:
      - api
    networks:
      - backend
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=shop
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=shop
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    networks:
      - backend
    restart: unless-stopped

  mq:
    image: rabbitmq:3-management
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend
    restart: unless-stopped

volumes:
  db-data:

networks:
  frontend:
  backend:

This stack has 7 services, 2 networks, and 1 volume. nginx is the only service exposed to the host. All internal communication happens on the unexposed backend network.

Observability #

When running a multi-container stack, you need visibility into every service.

Logs — Compose has built-in log aggregation:

# All logs
docker compose logs

# Follow a specific service's logs
docker compose logs -f api

# Tail the last 100 lines
docker compose logs --tail 100

# With timestamps
docker compose logs -t

Health status — view the status of all services:

docker compose ps

Resource usage — view CPU and memory per service:

docker stats

For production-grade observability (metrics, distributed tracing), you need extra tools: Prometheus + Grafana, Datadog, New Relic, or equivalent platforms. Compose itself doesn’t provide built-in observability.

Migrating to Production #

When a stack is ready for production, several things need to change.

1. External database — Don’t run production databases in single-host Compose. Use a managed database (RDS, Cloud SQL) or a multi-host setup with replication.

2. Secret management — Replace .env with a proper secret manager: AWS Secrets Manager, HashiCorp Vault, or Kubernetes Secrets.

3. Reverse proxy + TLS — In production, always terminate TLS at the reverse proxy. Cert-manager on Kubernetes, or Caddy/Traefik, can automatically manage Let’s Encrypt certificates.

4. Resource limits — Set CPU and memory limits so one service doesn’t disturb another.

5. Log aggregation — Send logs to centralized logging (CloudWatch, Stackdriver, ELK, Loki) for retention and searchability.

6. Health monitoring — Integrate with alerting (PagerDuty, OpsGenie) for incident notifications.

7. Backup strategy — Databases must be backed up regularly to separate storage. Test the restore procedure.

After all this, consider migrating to Kubernetes or a managed platform only if scale exceeds a single host.

Single-host Compose in production is OK for low-to-medium traffic. For high traffic, multi-host, high availability, or zero-downtime deploys, you need a more powerful orchestrator. Don’t force Compose onto workloads needing autoscaling or self-healing.

Summary #

  • Multi-container is Docker Compose’s main strength. Compose manages networking, dependencies, and lifecycle for several services as one stack.
  • Common patterns: three-tier, microservices, frontend+backend, worker+queue. Choose by application complexity.
  • Automatic service discovery — services in the same Compose file resolve each other via hostname = service name, with no manual DNS setup.
  • Multiple networks for defense in depth — separate public services (frontend) from sensitive ones (backend).
  • depends_on only waits for containers to start. To truly wait for readiness, use healthchecks + condition: service_healthy.
  • Shared volumes for data sharing between services. The init container pattern for setting up data before the main service starts.
  • --scale for multiple instances, but you need a reverse proxy/load balancer in front.
  • Multi-file Compose (-f override.yml) lets the same base configuration serve development, staging, and production.
  • Best practices: a clear dependency graph, healthchecks for critical services, explicit image tags, resource limits, secrets via .env/secrets:, volumes for persistent data.
  • Compose fits dev/test/small prod. For multi-host and high availability, you need Kubernetes or a managed platform.

← Previous: docker-compose.yml   Next: Environment Variable →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact