Real applications aren't a single container. They're a web of services — APIs, databases, caches, workers — that must start together, talk to each other, and shut down cleanly. Compose gives you declarative, reproducible multi-container environments in a single file.

1. The Problem

A typical web application needs at minimum:

  • An API server (Node, Python, Go…)
  • A database (Postgres, MySQL, MongoDB…)
  • A cache (Redis, Memcached…)
  • Maybe a background worker, a message queue, or a reverse proxy

Running each manually with docker run means:

# Create network
docker network create myapp

# Start database
docker run -d --name db --network myapp \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16

# Start Redis
docker run -d --name redis --network myapp redis:7-alpine

# Build and start API
docker build -t myapi ./api
docker run -d --name api --network myapp \
  -p 3000:3000 \
  -e DATABASE_URL=postgres://db:5432/app \
  myapi

That's three commands just to start — plus you need to remember the order, the flags, the environment variables, and to create the network first. Now imagine tearing it all down, or sharing this setup with a teammate. It's tedious, error-prone, and undocumented.

The Real Cost

The problem isn't typing — it's knowledge loss. Two weeks later, you won't remember which env vars the API needs, which ports to map, or what volume name you used. The setup lives in your shell history, not in version control.

2. What is Compose?

Docker Compose is a tool that reads a YAML file (compose.yaml) describing your multi-container application and manages its entire lifecycle with simple commands.

  • Declarative — you describe the desired state, Compose figures out what to create/start/stop
  • Single file — all services, networks, and volumes defined in one place
  • Single commanddocker compose up starts everything; docker compose down stops everything
  • Version-controlled — the file lives in your repo, documenting your stack
Compose is NOT an Orchestrator

Compose runs containers on a single host. It doesn't handle multi-node scheduling, rolling updates, or self-healing. It's designed for local development and testing. For production multi-host workloads, you need Kubernetes or Docker Swarm.

File naming: The modern standard is compose.yaml. The older docker-compose.yml still works but is legacy. No version: key is needed in modern Compose files.

3. The compose.yaml Anatomy

Here's a complete, annotated example — a Node API backed by Postgres and Redis:

# compose.yaml — Full-stack application
services:
  api:
    build: ./api                              # Build from Dockerfile in ./api
    ports:
      - "3000:3000"                           # Expose to host
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/app
      REDIS_URL: redis://redis:6379
    depends_on:
      - db
      - redis
    develop:
      watch:
        - action: sync
          path: ./api/src
          target: /app/src

  db:
    image: postgres:16                        # Use pre-built image
    volumes:
      - pgdata:/var/lib/postgresql/data       # Persist data
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine                     # Lightweight Redis
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:                                     # Named volume declaration

Key Breakdown

KeyPurposeExample
servicesTop-level map of all containers in the appservices: { api, db, redis }
imageUse a pre-built image from a registryimage: postgres:16
buildBuild an image from a Dockerfile pathbuild: ./api
portsMap host port → container port (external access)"3000:3000"
environmentSet environment variables in the containerPOSTGRES_PASSWORD: secret
volumesMount named volumes or bind mounts for persistencepgdata:/var/lib/postgresql/data
depends_onControl startup order (not readiness!)depends_on: [db, redis]
healthcheckDefine how to test if the service is readytest: ["CMD", "redis-cli", "ping"]

4. Networking in Compose

Compose automatically creates a default network for your project. Every service joins it. The magic:

  • Services resolve each other by service namedb, redis, api are DNS names
  • No port mapping needed between services — they communicate on internal ports directly
  • ports: is only needed for external access (host → container)
  • The network is named <project>_default (project = directory name by default)
Compose Default Network (myapp_default) api Node.js :3000 build: ./api db Postgres :5432 image: postgres:16 pgdata volume redis Redis :6379 db:5432 redis:6379 Host :3000
DNS Resolution

When the API connects to postgres://db:5432/app, Docker's embedded DNS resolves db to the container's IP on the Compose network. No hardcoded IPs, no --link (deprecated), no manual network creation.

5. Key Commands

CommandPurpose
docker compose up -dStart all services in detached mode
docker compose downStop and remove containers, networks
docker compose down -vAlso remove named volumes (data loss!)
docker compose psList running services and their status
docker compose logs -f apiFollow logs for a specific service
docker compose exec db psql -U postgresRun a command inside a running service
docker compose buildRebuild images (when Dockerfiles change)
docker compose pullPull latest images for services using image:
docker compose restart apiRestart a single service
# Typical workflow
$ docker compose up -d
[+] Running 4/4
 ✔ Network myapp_default   Created
 ✔ Volume "myapp_pgdata"   Created
 ✔ Container myapp-db-1    Started
 ✔ Container myapp-redis-1 Started
 ✔ Container myapp-api-1   Started

$ docker compose ps
NAME              SERVICE   STATUS    PORTS
myapp-api-1       api       running   0.0.0.0:3000->3000/tcp
myapp-db-1        db        running   5432/tcp
myapp-redis-1     redis     running   6379/tcp

$ docker compose logs api --tail 5
myapp-api-1  | Server listening on port 3000
myapp-api-1  | Connected to PostgreSQL
myapp-api-1  | Connected to Redis

$ docker compose down
[+] Running 4/4
 ✔ Container myapp-api-1    Removed
 ✔ Container myapp-redis-1  Removed
 ✔ Container myapp-db-1     Removed
 ✔ Network myapp_default    Removed

6. depends_on & Startup Order

The depends_on key controls startup order — but has a critical subtlety:

depends_on ≠ "ready"

By default, depends_on only waits for the dependency container to start — not for the service inside to be ready. Postgres takes seconds to initialize; your API might crash connecting before it's ready.

The Fix: Health-Based Dependencies

services:
  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy        # Wait for healthcheck to pass
      redis:
        condition: service_healthy

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

With condition: service_healthy, Compose waits until the dependency's healthcheck passes before starting the dependent service. This is the correct way to handle startup ordering.

ConditionWaits For
service_startedContainer started (default)
service_healthyHealthcheck passing
service_completed_successfullyContainer exited with code 0 (for init tasks)

7. Profiles & Overrides

Override Files

Compose automatically merges compose.yaml with compose.override.yaml if present. Use this for dev-specific configuration:

# compose.override.yaml — dev overrides (auto-loaded)
services:
  api:
    build:
      target: development                # Use dev stage of multi-stage build
    volumes:
      - ./api/src:/app/src               # Hot reload with bind mount
    environment:
      DEBUG: "true"

  db:
    ports:
      - "5432:5432"                      # Expose DB to host for GUI tools

Profiles

Profiles let you define optional services that only start when explicitly requested:

services:
  api:
    build: ./api
    ports: ["3000:3000"]

  db:
    image: postgres:16

  # Only starts with --profile debug
  pgadmin:
    image: dpage/pgadmin4
    ports: ["8080:80"]
    profiles: [debug]
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@local.dev
      PGADMIN_DEFAULT_PASSWORD: admin

  # Only starts with --profile monitoring
  prometheus:
    image: prom/prometheus
    profiles: [monitoring]
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
# Start core services only
$ docker compose up -d

# Start core + debugging tools
$ docker compose --profile debug up -d

# Start everything
$ docker compose --profile debug --profile monitoring up -d

Interactive Quizzes

Hands-On Task

🧪 Build a Full-Stack App with Compose

Create a three-service application: a Node.js API, Postgres database, and Redis cache.

Step 1: Create the project structure

mkdir compose-lab && cd compose-lab
mkdir api

Step 2: Create api/package.json

{
  "name": "compose-lab-api",
  "scripts": { "start": "node server.js" },
  "dependencies": { "express": "^4.18.0", "pg": "^8.11.0", "redis": "^4.6.0" }
}

Step 3: Create api/server.js

const express = require('express');
const { Pool } = require('pg');
const { createClient } = require('redis');

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = createClient({ url: process.env.REDIS_URL });

async function start() {
  await redis.connect();
  app.get('/', async (req, res) => {
    const dbResult = await pool.query('SELECT NOW() as time');
    await redis.incr('hits');
    const hits = await redis.get('hits');
    res.json({ db_time: dbResult.rows[0].time, hits });
  });
  app.listen(3000, () => console.log('API on :3000'));
}
start();

Step 4: Create api/Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

Step 5: Create compose.yaml

services:
  api:
    build: ./api
    ports: ["3000:3000"]
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/app
      REDIS_URL: redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:

Step 6: Run it

# Start everything
docker compose up -d

# Check status
docker compose ps

# Test the API
curl http://localhost:3000
# → {"db_time":"2024-...","hits":"1"}

# Hit it again
curl http://localhost:3000
# → {"db_time":"2024-...","hits":"2"}

# Check logs
docker compose logs api

# Connect to Postgres
docker compose exec db psql -U postgres -d app -c "SELECT NOW();"

# Tear it all down
docker compose down

✅ Success criteria: The API returns database time and an incrementing hit counter from Redis. All three services start with one command and stop with one command.

🌐 Not Just Docker

Podman Compose — a drop-in replacement that uses Podman instead of Docker. Same compose.yaml, same commands, rootless by default.

Podman play kube — Podman can also run Kubernetes YAML directly: podman play kube compose.yaml. This creates pods (groups of containers sharing a network namespace) — closer to how Kubernetes works.

Nerdctl — containerd's CLI also supports nerdctl compose up with the same file format.

🏭 Industry Pattern: Compose → Kubernetes

The dominant workflow in the industry:

  • Local dev: compose.yaml — fast iteration, simple commands
  • CI/testing: docker compose up in a pipeline for integration tests
  • Production: Kubernetes manifests (Deployments, Services, ConfigMaps) — multi-node, self-healing, scalable

Tools like Kompose can convert a compose.yaml into Kubernetes manifests as a starting point. But production configs always diverge — don't expect a 1:1 translation.

Key Takeaways

  • Compose = declarative multi-container — one YAML file defines your entire local stack
  • Service names are DNS names — containers reach each other by service name, no manual networking
  • ports: is for external access only — services communicate internally without port mapping
  • depends_on ≠ ready — use condition: service_healthy with healthchecks for real ordering
  • Named volumes persist data — survive docker compose down (unless you add -v)
  • Profiles for optional services — keep your default startup fast, add debugging/monitoring on demand
  • Compose is single-host only — for production multi-node, graduate to Kubernetes or Swarm
  • Version control your compose.yaml — it's documentation, reproducibility, and onboarding in one file