Back to blog

Running Multiple Services with Docker Compose

Bringing up an app, a database and a cache from a single compose.yaml — services, networks, volumes, healthchecks, env files and everyday commands.
dockerdevopsguide

Introduction

Running a single container with docker run is easy; but a real application usually consists of an API, a database, a cache and maybe a reverse proxy. Starting each of them with separate docker run commands and the right network and volume settings is tedious and error-prone.

Docker Compose lets you define all of these services in one YAML file and manage them with a single command. This post walks through a Spring Boot API + PostgreSQL + Redis example.

Compose is now part of the Docker CLI: the command is docker compose, not docker-compose. The recommended file name is compose.yaml; docker-compose.yml still works.

1. A first compose.yaml

services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/app
      SPRING_DATASOURCE_USERNAME: app
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_DATA_REDIS_HOST: cache
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

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

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

A few things to note:

  • Service names are DNS names. The api container reaches the database at db, not localhost. Compose puts all services on a shared network.
  • ports is only needed for services exposed to the outside. db and cache publish no host ports, so they're reachable only from inside the compose network.
  • depends_on + condition: service_healthy makes the API wait until the database actually accepts connections. Plain depends_on: [db] only waits for the container to start, not to be ready.
  • Named volumes (pgdata, redisdata) survive docker compose down; your data isn't lost.

2. Environment variables and .env

Variables like ${DB_PASSWORD} are read from a .env file next to compose.yaml:

# .env
DB_PASSWORD=local-dev-password
COMPOSE_PROJECT_NAME=myapp

To pass many variables into a container, env_file is cleaner:

services:
  api:
    env_file:
      - .env.api

Add .env files to .gitignore and commit a .env.example instead.

To see how Compose resolves the variables:

docker compose config        # prints the final file with variables substituted

3. Everyday commands

docker compose up -d                 # start all services in the background
docker compose up -d --build         # rebuild images, then start
docker compose up -d api             # only api (and its dependencies)
docker compose ps                    # service status
docker compose logs -f api           # follow api logs
docker compose logs --tail=100       # last 100 lines of every service
docker compose exec db psql -U app   # run a command in a running container
docker compose run --rm api sh       # one-off new container
docker compose restart api
docker compose stop                  # stop, keep containers
docker compose down                  # stop and remove containers + network
docker compose down -v               # remove volumes too (data is lost!)
docker compose pull                  # pull newer image versions
docker compose build --no-cache api
docker compose top                   # processes in the containers
CommandContainersNetworkVolumes
stopStopped, keptKeptKept
downRemovedRemovedKept
down -vRemovedRemovedRemoved

4. Overrides for development

Compose automatically reads compose.override.yaml alongside compose.yaml. Putting development-only settings there keeps the main file close to production:

# compose.override.yaml
services:
  api:
    environment:
      SPRING_PROFILES_ACTIVE: dev
    ports:
      - "5005:5005"     # remote debug
  db:
    ports:
      - "5432:5432"     # to connect with DBeaver etc.

To use a different file in production, list the files explicitly with -f:

docker compose -f compose.yaml -f compose.prod.yaml up -d

5. Profiles

Services that don't always need to run (e.g. pgAdmin, a mail test server) can be tied to a profile:

services:
  pgadmin:
    image: dpage/pgadmin4
    profiles: ["tools"]
    ports:
      - "5050:80"
    environment:
      PGADMIN_DEFAULT_EMAIL: [email protected]
      PGADMIN_DEFAULT_PASSWORD: admin
docker compose up -d                     # pgadmin doesn't start
docker compose --profile tools up -d     # pgadmin starts too

6. Resource limits and restart policy

services:
  api:
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
restart valueBehavior
noDefault, never restarts
alwaysAlways restarts
on-failureOnly when it exits with an error code
unless-stoppedRestarts unless stopped manually

7. Troubleshooting

ProblemFix
Connection refused → localhost:5432From inside a container use the service name: db:5432
API starts before the database and crashesAdd depends_on + condition: service_healthy and a healthcheck
port is already allocatedSomething on the host uses that port; find it with ss -ltnp or change the mapping
Code changes don't reach the containerRebuild the image with docker compose up -d --build
Changed the DB password but the old one still worksPostgres writes the password to the volume on first init; remove it with down -v (data is lost)
.env variables come through emptyCheck with docker compose config; .env must sit next to the compose file

Conclusion

Docker Compose is the most practical way to define a multi-service application in one file and let everyone bring up the same environment with one command. Remembering that service names are DNS names, controlling startup order with a healthcheck, and knowing that down -v deletes data prevents most of the problems you'll run into.