✦ For everyone, free.

Practical knowledge for real and everyday life

Home

20.2.1.1 Compose Multi Service

A focused guide to Compose Multi Service, connecting core concepts with practical Docker and container operations.

A multi-service Compose application defines several containers that work together as a system. Each container is responsible for one service — web server, database, cache, message queue, background worker — and Compose manages their shared network, volumes, startup order, and lifecycle as a single unit. This is the primary pattern for running realistic application stacks in development and on single-host production servers.

Anatomy of a Multi-Service compose.yml

A three-service application — Node.js API, PostgreSQL database, Redis cache — defined in a single Compose file:

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    volumes:
      - cache_data:/data

volumes:
  db_data:
  cache_data:

How Services Find Each Other

Compose creates a private Docker network for the application and registers each service name as a DNS hostname. The api service connects to PostgreSQL using the hostname db and to Redis using the hostname cache. These names resolve inside the Compose network automatically — no hardcoded IP addresses, no manual network configuration.

The connection strings in the environment variables reflect this:

DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/app
REDIS_URL: redis://cache:6379

db and cache are the service names from the compose.yml. The ports (5432, 6379) are the container-internal ports, not any host-published ports.

Starting the Stack

docker compose up -d

Compose pulls any missing images (postgres:15-alpine, redis:7-alpine), builds the api service from the local Dockerfile, creates the named volumes (db_data, cache_data), creates the network, and starts all containers. The depends_on conditions ensure the database passes its healthcheck before the API starts.

Verifying All Services

docker compose ps
NAME          IMAGE              STATUS          PORTS
myapp-api-1   myapp-api          running         0.0.0.0:3000->3000/tcp
myapp-db-1    postgres:15-alpine running(healthy) 5432/tcp
myapp-cache-1 redis:7-alpine     running         6379/tcp

The (healthy) status next to db indicates the healthcheck is passing.

Watching Logs Across All Services

docker compose logs -f

Streams logs from all three services simultaneously, each line prefixed with the service name:

myapp-api-1    | Server listening on port 3000
myapp-db-1     | database system is ready to accept connections
myapp-cache-1  | Ready to accept connections

To watch logs for one service only:

docker compose logs -f api

Restarting a Single Service

During development, when you change the application code and rebuild:

docker compose up -d --build api

Compose rebuilds the api image and restarts only that container, leaving db and cache running undisturbed.

Running Database Migrations

Use docker compose exec to run a command inside a running service container:

docker compose exec api npm run migrate

This runs the migration command inside the api container, where the DATABASE_URL environment variable is already set and the container has network access to db.

Scaling a Service

To run multiple instances of a stateless service:

docker compose up -d --scale api=3

Compose starts three containers for the api service. Note that port publishing (ports: - "3000:3000") conflicts with scaling when the same host port is mapped — you would need to remove the fixed port mapping and use a load balancer or proxy to distribute traffic.

Stopping and Cleaning Up

Stop all services without removing them:

docker compose stop

Stop and remove containers and networks, keeping volumes:

docker compose down

Stop and remove everything including volumes (deletes data):

docker compose down -v

Separate Concerns with Multiple Compose Files

Keep the base compose.yml clean for production use, and add development-only overrides in compose.override.yml, which Compose loads automatically:

compose.override.yml:

services:
  api:
    volumes:
      - .:/app
    environment:
      NODE_ENV: development
    command: npm run dev

In development, the source code directory is bind-mounted into the container so changes are reflected immediately without rebuilding the image. In production (where the override file is not present), the image's built-in code is used.

Environment Variable Management

Use a .env file in the project directory for secrets and environment-specific values:

POSTGRES_PASSWORD=devsecret123
APP_ENV=development

Compose reads .env automatically. Add .env to .gitignore so secrets are never committed. Provide a .env.example file with placeholder values that can be committed safely.

Service Topology Visualization

myapp_default network api :3000 db :5432 cache :6379 Host :3000

The api service publishes port 3000 to the host. The db and cache services have no published ports — they are reachable only from within the Compose network, reducing the host's exposed attack surface.

Common Patterns in Multi-Service Compose

Database initialization: Mount SQL files into the PostgreSQL container's /docker-entrypoint-initdb.d/ directory to run initialization scripts on first startup:

db:
  image: postgres:15-alpine
  volumes:
    - db_data:/var/lib/postgresql/data
    - ./init.sql:/docker-entrypoint-initdb.d/init.sql

Service restart policy: Add restart: unless-stopped to services that should recover from failures automatically:

services:
  api:
    restart: unless-stopped
  db:
    restart: unless-stopped

Named networks: To isolate groups of services from each other, define explicit networks and assign services to them, preventing services from communicating across security boundaries.