Free Ebook cover Docker for Beginners: Containers Explained with Simple Projects

Docker for Beginners: Containers Explained with Simple Projects

New course

12 pages

Defining Multi-Service Setups with Docker Compose

Capítulo 10

Estimated reading time: 10 minutes

+ Exercise

Why Docker Compose for multi-service setups

As projects grow, you rarely run just one container. A typical development setup might include a web app, an API, a database, a cache, and a reverse proxy. Running each container with separate commands quickly becomes repetitive and error-prone: you have to remember environment variables, port mappings, volume paths, and the correct startup order. Docker Compose solves this by letting you define an entire multi-service application in a single file and manage it with a small set of commands.

Docker Compose is not “a different kind of container.” It is a tool and a file format that describes how multiple containers should run together: which images to use (or how to build them), how they connect, what configuration they need, and what data should persist. The key idea is that your application’s runtime topology becomes code: a declarative configuration you can version, review, and share.

The Compose file: the blueprint for your stack

Docker Compose uses a YAML file (commonly named docker-compose.yml or compose.yml) to describe services. A service is typically one container type (for example, web, api, db). Compose then creates containers, networks, and volumes based on that description.

Core building blocks you will use most

  • services: the containers that make up your app.
  • image: which prebuilt image to run.
  • build: how to build an image from a Dockerfile for a service.
  • ports: publish container ports to the host for local access.
  • environment: configure services via environment variables.
  • depends_on: express startup ordering between services.
  • networks: define how services communicate (Compose creates a default network if you do nothing).
  • volumes: define persistent storage and shareable data locations.

Compose is designed to be readable. The file is meant to answer: “What are the parts of this system, and how do they fit together?”

A first multi-service example: web + API + database

Below is a practical Compose file that defines three services: a web frontend, an API, and a database. The web and API are built from local Dockerfiles, while the database uses an official image. This example focuses on Compose structure and common options.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

services:
  web:
    build: ./web
    ports:
      - "8080:80"
    environment:
      - API_BASE_URL=http://api:3000
    depends_on:
      - api

  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://app:app@db:5432/appdb
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=app
      - POSTGRES_DB=appdb

Important details to notice:

  • Service names become DNS names. Inside the Compose network, the API can reach the database at db:5432, and the web can reach the API at api:3000. You do not need to hardcode IP addresses.
  • Host ports are for you, container ports are for containers. The web is reachable on your machine at localhost:8080, but other services should use the internal service name and container port.
  • depends_on is not a health check. It ensures Compose starts containers in order, but it does not guarantee the database is ready to accept connections. You’ll learn practical patterns for readiness later in this chapter.

Step-by-step: creating and running the Compose setup

1) Create a project folder structure

Compose works best when each project has its own directory. A common layout is:

myapp/
  compose.yml
  web/
    Dockerfile
    ...
  api/
    Dockerfile
    ...

Put the Compose file at the root so it can reference relative paths like ./web and ./api.

2) Write the Compose file

Create compose.yml (or docker-compose.yml) and define your services. Start minimal: define services, then add ports and environment variables as needed.

3) Start the stack

From the project directory, run:

docker compose up

This builds images (if build is used), creates a network, and starts containers. Add -d to run in the background:

docker compose up -d

4) View logs per service

Compose aggregates logs, which is extremely useful in multi-service debugging:

docker compose logs -f

To focus on one service:

docker compose logs -f api

5) Stop and remove containers

To stop services:

docker compose stop

To stop and remove containers and the default network:

docker compose down

If you also want to remove named volumes created by Compose (be careful, this deletes persisted data):

docker compose down -v

Understanding Compose networking in practice

When you run docker compose up, Compose creates a dedicated network for the project (unless you configure otherwise). All services join that network automatically. This gives you:

  • Service discovery by name (use db, api, web as hostnames).
  • Isolation from other Compose projects (each project gets its own network by default).
  • Predictable connectivity without manual network creation.

A common beginner mistake is to connect from one container to another using localhost. Inside a container, localhost means “this same container,” not another service. In Compose, use the service name: http://api:3000, not http://localhost:3000.

Configuration patterns: environment variables and .env files

Compose supports two closely related ideas:

  • Environment variables passed into containers via the environment section.
  • Variable substitution in the Compose file using values from your shell or a local .env file.

Variable substitution lets you avoid hardcoding values like ports or passwords in the YAML. Example:

services:
  api:
    build: ./api
    environment:
      - NODE_ENV=${NODE_ENV:-development}
      - DATABASE_URL=${DATABASE_URL}

Then create a .env file next to compose.yml:

NODE_ENV=development
DATABASE_URL=postgres://app:app@db:5432/appdb

Notes:

  • .env is for Compose variable substitution. It is not automatically injected into containers unless you reference the variables in environment or use an env file feature.
  • Defaults with :- are helpful for local development.
  • Keep secrets out of Git by adding .env to .gitignore when it contains sensitive values.

Building images with Compose: build contexts and overrides

When you use build, Compose can build images for you. The simplest form is a path to the build context:

services:
  api:
    build: ./api

You can also specify a Dockerfile name and build arguments:

services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
      args:
        - APP_VERSION=1.0.0

Compose will rebuild when needed. If you want to force a rebuild:

docker compose build --no-cache

Or rebuild and start:

docker compose up --build

Managing startup order and readiness

Multi-service setups often fail on first boot because one service tries to connect to another before it is ready. Compose offers depends_on for ordering, but ordering alone does not guarantee readiness.

Practical pattern: add health checks and wait for healthy dependencies

You can define a healthcheck for a service and then use a dependency condition so another service waits until it is healthy. Example with Postgres:

services:
  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=app
      - POSTGRES_DB=appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgres://app:app@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy

This is a practical improvement for local development because it reduces “connection refused” loops at startup. If your Compose implementation does not support the condition form, you can still keep the healthcheck for visibility and implement retry logic in the API (many frameworks already do).

Named volumes in Compose for stateful services

Stateful services (like databases) need persistent storage. In Compose, you typically define a named volume and mount it into the service. This keeps data across container restarts and recreations.

services:
  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=app
      - POSTGRES_DB=appdb
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:

Key points:

  • Named volumes are managed by Docker, not tied to a specific host path.
  • Compose names resources per project. The actual volume name will usually be prefixed with the project name.
  • Removing volumes is explicit (for example, docker compose down -v), which helps prevent accidental data loss.

Profiles: run optional services only when needed

Sometimes you want extra services only in certain situations: a local mail catcher, a debugging UI, or a metrics stack. Compose profiles let you mark services as optional and start them only when requested.

services:
  api:
    build: ./api

  mailhog:
    image: mailhog/mailhog
    profiles: ["devtools"]
    ports:
      - "8025:8025"

Start the default services:

docker compose up

Start with the optional profile:

docker compose --profile devtools up

This keeps your main stack lean while still making helpful tools easy to enable.

Scaling stateless services for local testing

Compose can run multiple instances of a service, which is useful for testing load balancing behavior or concurrency issues. For example, to run three API containers:

docker compose up --scale api=3

Important considerations:

  • Port publishing conflicts: if your service publishes a fixed host port (like "3000:3000"), scaling will fail because multiple containers cannot bind the same host port. For scaled services, avoid publishing fixed host ports and access them through another service (like a reverse proxy) or use internal networking.
  • Stateful services should not be scaled casually: databases and similar services require clustering strategies beyond basic scaling.

Adding a reverse proxy service (common multi-service pattern)

A reverse proxy can route requests to multiple backend services and provide a single entry point. In local development, it also helps when scaling services because only the proxy needs a published host port.

Here is a simple example using Nginx as a gateway in front of an API and a web app. The Nginx configuration is mounted into the container.

services:
  gateway:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - web
      - api

  web:
    build: ./web

  api:
    build: ./api

A minimal gateway/nginx.conf might route /api to the API service and everything else to the web service. The important Compose concept is that the proxy can reach api and web by service name on the internal network.

Compose project naming and resource isolation

Compose groups resources (containers, networks, volumes) into a “project.” By default, the project name is derived from the directory name. This matters because:

  • Two different projects can both have a service called db without conflict, because their networks are separate.
  • Resource names are typically prefixed with the project name, making it easier to identify what belongs together.

You can set a custom project name when running commands:

docker compose -p myapp-dev up -d

This is useful when you want to run two copies of the same stack (for example, myapp-dev and myapp-test) on the same machine without collisions.

Override files for development vs. other environments

A common Compose workflow is to keep a base file that describes the core services, and then add a second file that overrides settings for local development (for example, extra port mappings, debug flags, or bind mounts). Compose can merge multiple files.

Base file (compose.yml):

services:
  api:
    build: ./api
    environment:
      - NODE_ENV=production

Development overrides (compose.override.yml):

services:
  api:
    environment:
      - NODE_ENV=development
    ports:
      - "3000:3000"

By default, Compose automatically uses compose.override.yml if present. You can also specify files explicitly:

docker compose -f compose.yml -f compose.override.yml up

This pattern keeps your core definition stable while allowing local convenience settings without polluting the base configuration.

Troubleshooting multi-service Compose setups

Inspect running services and their status

To see what Compose started:

docker compose ps

This shows container names, state, and published ports.

Exec into a specific service

When debugging connectivity or configuration, it’s often helpful to run commands inside a container:

docker compose exec api sh

From there, you can test DNS resolution (service names), check environment variables, or run application-specific diagnostics.

Recreate a service after config changes

If you change the Compose file, Compose may need to recreate containers to apply changes:

docker compose up -d

To force recreation:

docker compose up -d --force-recreate

Common mistakes and quick fixes

  • Using localhost between services: replace with the service name (for example, db, api).
  • Port confusion: containers talk to container ports; host ports are only for access from your machine.
  • Startup race conditions: add health checks and/or retry logic.
  • Stale containers after changes: rebuild with --build or recreate with --force-recreate.
  • Data issues during testing: use separate named volumes per project name, or remove volumes intentionally with down -v when you want a clean slate.

Now answer the exercise about the content:

In a Docker Compose setup, what is the correct way for one service container to reach another service container?

You are right! Congratulations, now go to the next page

You missed! Try again.

Compose creates a project network and provides service discovery by name. From one container, use the other service name (like api or db) and its container port; localhost refers only to the same container.

Next chapter

Troubleshooting Containers, Builds, and Connectivity

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.