Multi-Service Applications with Compose

Docker Compose lets you describe a whole application stack in one file instead of starting each container by hand. For a real app, that usually means a web service, a database, a cache, background workers, and shared networks or volumes. Compose matters because it turns a pile of docker run commands into a repeatable project definition that teammates and CI systems can run the same way.

Overview: How Compose Runs Multiple Services

A Compose application is a project made of named services. Each service becomes one or more containers created from either an image, such as postgres:16-alpine, or a local build context, such as ./api. Compose also creates default resources for the project: a private bridge network so services can reach each other by service name, named volumes for persistent data, and containers whose names are tied to the project and service.

The important idea is that Compose is still Docker. It does not replace images, containers, networks, or volumes. It calls the Docker Engine API through the modern docker compose CLI plugin and asks the daemon to build images, create networks, create volumes, and start containers. On Linux those containers use Linux namespaces and cgroups directly. On Docker Desktop, the daemon runs inside a small Linux VM, but the Compose model is the same.

In a multi-service app, service discovery is built in. If a service is named db, another service can connect to hostname db on the container port, for example postgres://app:changeme@db:5432/appdb. You do not need to publish the database port to the host just so the web container can reach it. The ports: key is only for host-to-container access, such as opening localhost:8080 in your browser.

Compose startup order is often misunderstood. depends_on controls the order in which Compose starts containers, but it does not automatically mean the database is ready to accept connections. For readiness-sensitive apps, add a healthcheck to the dependency and use a condition such as service_healthy, or make the application retry database connections on startup. Retrying in the app is still a best practice because databases can restart later too.

Syntax

A typical multi-service Compose file uses this shape:

services:
  web:
    build: ./web
    ports:
      - "8080:8080"
    environment:
      API_URL: "http://api:3000"
    depends_on:
      api:
        condition: service_started
  api:
    image: my-api:1.0
    environment:
      DATABASE_URL: "postgres://app:changeme@db:5432/appdb"
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
volumes:
  db-data:
Key Purpose
services Defines the containers that make up the app. Each child key, such as api or db, becomes a DNS name on the Compose network.
image Runs an existing image. Use specific tags instead of latest for reproducible environments.
build Builds an image from a local Dockerfile before starting the service.
ports Publishes a container port to the host. This is different from EXPOSE, which is only image metadata.
environment Sets container environment variables. Use obvious placeholders such as changeme in examples; do not bake real secrets into images.
depends_on Declares startup dependencies. With health checks, it can wait for a dependency to become healthy before starting another service.
volumes Mounts persistent or shared storage. Named volumes are managed by Docker and are the usual choice for database data.
networks Optionally customizes networks. If omitted, Compose creates one default network for the project.

The main commands are:

docker compose up -d
docker compose ps
docker compose logs api
docker compose down
  • up creates missing resources, builds images when needed, and starts services.
  • -d runs containers in the background.
  • ps lists containers for the current Compose project.
  • logs SERVICE shows logs for one service; omit the service name to show all logs.
  • down stops and removes the project containers and default network. It does not remove named volumes unless you add --volumes.

Examples

Example 1: Web Plus Redis

This Compose file starts a small web container and a Redis cache. The web service can connect to Redis using hostname redis and port 6379 inside the private Compose network.

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    depends_on:
      redis:
        condition: service_started
  redis:
    image: redis:7.2-alpine
docker compose up -d

Output:

[+] Running 3/3
 - Network demo_default    Created
 - Container demo-redis-1  Started
 - Container demo-web-1    Started

Only web publishes a port. Redis stays private to the Compose network, which is usually what you want for infrastructure services. If you open http://localhost:8080, the request enters the Nginx container on port 80.

Example 2: API, Database, and Persistent Data

A common application has an API and a database. This file uses a named volume so PostgreSQL data survives container replacement.

services:
  api:
    image: node:20-alpine
    working_dir: /app
    command: ["node", "server.js"]
    volumes:
      - ./api:/app
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: "postgres://app:changeme@db:5432/appdb"
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
volumes:
  db-data:
docker compose up -d
docker compose ps

Output:

[+] Running 3/3
 - Network shop_default  Created
 - Container shop-db-1   Healthy
 - Container shop-api-1  Started
NAME          IMAGE                 COMMAND                  SERVICE   STATUS
shop-api-1    node:20-alpine         "docker-entrypoint.sh"   api       Up
shop-db-1     postgres:16-alpine     "docker-entrypoint.sh"   db        Up (healthy)

The API bind-mounts ./api for local development, while the database uses a named volume. That distinction matters: a bind mount points at a host path you choose, but a named volume is managed by Docker and remains available even if the db container is deleted.

Example 3: Build One Service, Pull Another

Compose can build your application image and pull supporting images in the same project. This Dockerfile uses a pinned base image and a cache-friendly dependency install order.

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

EXPOSE 3000 documents that the image listens on port 3000, but it does not publish the port to your host. Compose publishes it with ports:.

services:
  api:
    build: ./api
    image: shop-api:1.0
    ports:
      - "3000:3000"
    environment:
      REDIS_URL: "redis://cache:6379"
    depends_on:
      cache:
        condition: service_started
  cache:
    image: redis:7.2-alpine
docker compose up --build -d

Output:

[+] Building 8.4s (9/9) FINISHED
[+] Running 3/3
 - Network shop_default    Created
 - Container shop-cache-1  Started
 - Container shop-api-1    Started

Compose builds shop-api:1.0 from ./api, then starts it next to Redis. The Dockerfile copies dependency manifests before the rest of the source so ordinary source changes do not force npm ci to rerun unless dependency files changed.

How It Works Step by Step

  1. Compose chooses a project name, usually from the directory name unless you pass --project-name.
  2. It reads compose.yml, resolves environment interpolation, and compares desired services with existing project containers.
  3. For services with build, Docker sends the build context to the daemon. BuildKit evaluates Dockerfile layers and reuses cached layers when inputs have not changed.
  4. For services with image, Docker checks the local image store and pulls missing image layers from the registry if needed.
  5. Compose creates the project network and named volumes. Containers attached to the same network get DNS entries matching service names.
  6. Compose creates containers with their environment, mounts, commands, health checks, and port publishing rules.
  7. When you run docker compose down, Compose removes project containers and networks. Named volumes remain unless you explicitly request removal.

Common Mistakes

Publishing Every Port

services:
  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"

This is often unnecessary in a multi-service app. Other services can already reach db:5432 on the Compose network. Publish the database port only when a host tool, such as a desktop SQL client, needs direct access. Otherwise omit ports:.

Assuming depends_on Means Ready

services:
  api:
    image: shop-api:1.0
    depends_on:
      - db
  db:
    image: postgres:16-alpine

This starts db before api, but the database process may still be initializing. Add a health check and wait for service_healthy, and make the API retry its connection because a healthy dependency can still restart later.

Losing Data on down –volumes

docker compose down --volumes

This removes named volumes for the project. That is useful for resetting a development database, but it is the wrong command when you want to keep local data. Use plain docker compose down to remove containers while preserving named volumes.

Best Practices

  • Use docker compose, the modern Compose V2 subcommand, not the old standalone docker-compose command.
  • Keep databases, caches, and message brokers private unless the host truly needs access.
  • Use named volumes for persistent service data and bind mounts for local source-code development.
  • Pin image tags such as postgres:16-alpine and redis:7.2-alpine; avoid latest for repeatable projects.
  • Add health checks for dependencies that take time to become ready.
  • Do not store real secrets in a Dockerfile or committed Compose file. Use environment files excluded from version control, Docker secrets, or your deployment platform’s secret store.
  • Give services stable names that describe roles: api, web, db, cache, worker.
  • Keep the Compose file development-friendly, and use override files or separate deployment configuration when production needs differ.

Practice Exercises

  1. Create a Compose file with web using nginx:1.27-alpine and api using a locally built image from ./api. Publish only the web service to the host. Hint: the web service can call http://api:3000 by service name.
  2. Add PostgreSQL to an existing API project. The expected end state is an api service that waits for a healthy db service and a named volume that stores database files.
  3. Run docker compose down, then start the app again and confirm that database data remains. Then decide when docker compose down --volumes would be appropriate.

Summary

  • Compose describes a full application stack as services, networks, volumes, builds, and runtime settings.
  • Services on the same Compose network can reach each other by service name without publishing ports to the host.
  • depends_on controls startup order; health checks and application retries handle readiness.
  • Named volumes preserve important data across container replacement.
  • Compose is a convenient layer over normal Docker Engine behavior, so images, layers, containers, ports, and volumes still work the same way.