Docker Compose 101: Run Multiple Services Without Losing Your Mind

Posted November 22, 2025 by Karol Polakowski

Docker Compose is a small but powerful tool: it lets you describe a multi-container application in one file and bring it up with a single command. If you’ve ever juggled a Node process, a database, and a cache across multiple terminals and env files, Compose will save you time and prevent a lot of errors.

Why Compose (and when to use it)

Compose is ideal for local development, CI test environments, and simple staging stacks. It is not a replacement for Kubernetes for large-scale production deployments, but it frequently sits in the sweet spot for developer productivity:

  • One declarative file (docker-compose.yml / compose.yaml).
  • Consistent environment across machines and CI.
  • Easy lifecycle commands: up, down, logs, exec.

Use Compose when you need to run multiple dependent services (app + DB + cache + worker) locally or for small-scale testing.

Basic example

This minimal example shows a Node app, Postgres, and Redis. Save as docker-compose.yml next to your Dockerfile.

version: "3.8"
services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - ./:/usr/src/app:cached
      - /usr/src/app/node_modules
    environment:
      NODE_ENV: development
    depends_on:
      - db
      - redis
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 3

  db:
    image: postgres:14
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 10s

  redis:
    image: redis:7
    volumes:
      - redis-data:/data

volumes:
  db-data: {}
  redis-data: {}

Notes:

  • Use depends_on for start order, but prefer healthcheck + wait-for logic in the app for robust readiness.
  • Bind-mount the source for fast iteration; exclude node_modules by mounting a named anonymous path to avoid host/containing conflicts.

Common commands

# Start in foreground (good for debugging)
docker compose up

# Start detached
docker compose up -d

# Rebuild a service and restart
docker compose up -d --build app

# See status
docker compose ps

# Tail logs (all services)
docker compose logs -f

# Run a command in a running container
docker compose exec app sh

# Stop and remove containers, networks created by compose
docker compose down --volumes --remove-orphans

Tip: modern Docker supports both docker-compose (hyphen) and docker compose (space). Prefer the built-in “docker compose” where available.

Best practices and patterns

Keep config out of the YAML

Use env_file and .env to store runtime variables; avoid hardcoding secrets. For real secrets use Docker secrets or a secrets manager.

Use healthchecks for orchestration

depends_on only waits for container start, not readiness. Add healthcheck blocks and make your app retry DB connections.

Split dev and prod settings

Use override files (docker-compose.override.yml) or profiles so dev mounts the code while production uses built images.

Avoid permission and node_modules issues

For Node apps with bind mounts, map a host folder for node_modules or use an anonymous volume to let the container manage dependencies.

Keep volumes for persistent services

Use named volumes for DB data so containers can be replaced without losing state.

Limit exposed ports in CI

In CI, prefer internal networks and avoid mapping ports unless necessary. Compose creates a network per project by default.

Dev tricks that save time

  • docker compose up –build –force-recreate to rebuild and recreate containers.
  • Use profiles to enable heavy services only when needed (e.g., monitoring):
services:
  debug-tool:
    image: some/debug
    profiles: [dev-tools]

Start with a profile: docker compose –profile dev-tools up

  • Use depends_on + healthcheck for basic orchestration, but for robust startup use wait scripts or retry logic in your app.

Troubleshooting

Container exits immediately

Run docker compose logs and docker compose ps to see exit codes. Use docker compose run –service-ports sh to inspect the container interactively.

Port conflicts

Check running containers with docker ps and free the host port or change mapping in compose.

Database not ready

Even with depends_on, your app may attempt DB connections before the DB is fully ready. Add a retry/backoff in the app or use a small wait-for script.

Volume caching and slow mounts on macOS

Bind mounts can be slow on macOS/Windows. Use cached or delegated options (as in the example) or use Docker Desktop file sharing settings.

When Compose is not enough

Compose is not a drop-in production orchestrator for large clusters. For high-availability, scaling across hosts, or advanced scheduling, move to Kubernetes, ECS, or another orchestrator. However, Compose files can often be used as a reference when authoring Helm charts or Kubernetes manifests.

Example workflow: iterate fast

  1. docker compose up -d db redis
  2. docker compose up –build app (or use docker compose up -d –build)
  3. docker compose logs -f app
  4. When changing a Dockerfile, run docker compose up -d –build app
  5. When testing a clean DB: docker compose down –volumes && docker compose up -d

Conclusion

Docker Compose gives you a predictable, repeatable way to run multiple services locally. Use healthchecks, env_files, and volumes properly; split dev and prod settings; and prefer small, focused compose files. With these conventions you’ll spend less time juggling terminals and more time shipping features.

If you want, I can produce a starter repo template (Dockerfile + compose + healthcheck-ready app) tailored to Node, Python, or another stack—tell me which stack you prefer.