Docker Compose Orchestration with Python — Deep Dive

Architecture of Compose orchestration

Docker Compose V2 (the docker compose plugin) replaced the legacy docker-compose Python binary. Under the hood, it reads YAML service definitions and translates them into Docker API calls — creating networks, pulling images, and starting containers in dependency order.

When Python orchestrates Compose, it typically operates at one of three levels:

  1. Shell-out — subprocess calls to docker compose up/down
  2. SDK wrapping — libraries like python-on-whales that call the CLI and parse structured output
  3. Direct Docker API — the docker SDK communicating with the Docker daemon socket

Each level trades convenience for control.

Programmatic Compose with python-on-whales

from python_on_whales import DockerClient

docker = DockerClient(compose_files=["docker-compose.yml"])

# Start all services in background
docker.compose.up(detach=True, wait=True)

# Check service health
for container in docker.compose.ps():
    print(f"{container.name}: {container.state.status}")
    if container.state.health:
        print(f"  health: {container.state.health.status}")

# Stream logs from a specific service
for log_line in docker.compose.logs("api", follow=True, stream=True):
    print(log_line, end="")

The wait=True flag is crucial — it blocks until all services with health checks report healthy, not just until containers start. Without it, your Python code might try to connect to a database that hasn’t finished initializing.

Integration testing pattern

The most common use case for Python + Compose orchestration is integration testing. Here’s a production-grade pytest fixture:

import pytest
from python_on_whales import DockerClient

@pytest.fixture(scope="session")
def compose_stack():
    """Start the full stack once per test session."""
    docker = DockerClient(
        compose_files=["docker-compose.test.yml"],
        compose_project_name="test_run"
    )
    docker.compose.up(detach=True, wait=True, quiet=True)

    yield docker

    docker.compose.down(volumes=True, timeout=10)

@pytest.fixture
def db_url(compose_stack):
    """Extract the database URL from the running stack."""
    container = compose_stack.container.inspect("test_run-postgres-1")
    port = container.network_settings.ports["5432/tcp"][0].host_port
    return f"postgresql://test:test@localhost:{port}/testdb"

Key details:

  • scope="session" avoids restarting containers per test — sessions run once for the entire suite
  • compose_project_name isolates the test stack from dev environments
  • volumes=True in teardown prevents data leaking between CI runs
  • Port extraction handles dynamic host port mapping

CI/CD pipeline integration

GitHub Actions

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Start services
        run: docker compose -f docker-compose.test.yml up -d --wait
      - name: Run tests
        run: python -m pytest tests/integration/ -v
      - name: Collect logs on failure
        if: failure()
        run: docker compose -f docker-compose.test.yml logs --tail=100
      - name: Teardown
        if: always()
        run: docker compose -f docker-compose.test.yml down -v

Dynamic Compose generation

For complex test matrices, Python can generate Compose files programmatically:

import yaml

def generate_compose(db_version: str, cache_enabled: bool) -> dict:
    services = {
        "api": {
            "build": {"context": ".", "dockerfile": "Dockerfile"},
            "depends_on": {
                "db": {"condition": "service_healthy"}
            },
            "environment": {
                "DATABASE_URL": "postgresql://app:app@db:5432/app",
                "CACHE_ENABLED": str(cache_enabled).lower(),
            },
        },
        "db": {
            "image": f"postgres:{db_version}",
            "environment": {
                "POSTGRES_USER": "app",
                "POSTGRES_PASSWORD": "app",
                "POSTGRES_DB": "app",
            },
            "healthcheck": {
                "test": ["CMD-SHELL", "pg_isready -U app"],
                "interval": "2s",
                "timeout": "5s",
                "retries": 10,
            },
        },
    }

    if cache_enabled:
        services["redis"] = {
            "image": "redis:7-alpine",
            "healthcheck": {
                "test": ["CMD", "redis-cli", "ping"],
                "interval": "2s",
                "timeout": "3s",
                "retries": 5,
            },
        }
        services["api"]["depends_on"]["redis"] = {
            "condition": "service_healthy"
        }

    return {"version": "3.9", "services": services}

# Write and use
config = generate_compose(db_version="16", cache_enabled=True)
with open("docker-compose.generated.yml", "w") as f:
    yaml.dump(config, f)

Service readiness beyond health checks

Health checks verify a container process is running, but they don’t guarantee the service is ready for your specific use case. A robust Python orchestrator adds application-level readiness probes:

import time
import psycopg2
from psycopg2 import OperationalError

def wait_for_postgres(dsn: str, timeout: int = 30) -> None:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            conn = psycopg2.connect(dsn)
            conn.close()
            return
        except OperationalError:
            time.sleep(0.5)
    raise TimeoutError(f"PostgreSQL not ready after {timeout}s")

Compose profiles for environment variants

Profiles allow optional services without separate Compose files:

services:
  api:
    build: .
    ports: ["8000:8000"]

  db:
    image: postgres:16
    profiles: ["", "full"]

  redis:
    image: redis:7
    profiles: ["", "full"]

  mailhog:
    image: mailhog/mailhog
    profiles: ["debug"]
    ports: ["8025:8025"]

  pgadmin:
    image: dpage/pgadmin4
    profiles: ["debug"]
    ports: ["5050:80"]

Python can activate profiles dynamically:

docker = DockerClient(compose_files=["docker-compose.yml"])
docker.compose.up(detach=True, profiles=["full", "debug"])

Performance considerations

  • Build caching: Use docker compose build --parallel and multi-stage Dockerfiles to speed up image builds
  • Volume mounts vs copies: Bind mounts are convenient for development but add filesystem overhead; named volumes perform better for databases
  • Resource limits: Set deploy.resources.limits in Compose to prevent a runaway service from starving others
  • Layer caching in CI: Use docker compose pull before build to leverage registry caches, and consider BuildKit cache mounts for pip install layers

Tradeoffs vs Kubernetes

DimensionComposeKubernetes
Setup complexityMinutesHours to days
Multi-hostNo (single Docker daemon)Yes (cluster)
Auto-scalingNoYes (HPA)
Rolling updatesManual (docker compose up -d)Built-in
Service discoveryContainer names on shared networkDNS + Service objects
Best forDev, testing, small productionProduction at scale

Many teams use Compose for development/CI and Kubernetes for production, sharing the same Dockerfile and environment variable conventions across both.

Monitoring the orchestrated stack

from python_on_whales import DockerClient

docker = DockerClient(compose_files=["docker-compose.yml"])

for container in docker.compose.ps():
    stats = docker.container.stats(container.id, stream=False)
    cpu = stats[0].cpu_usage_percent
    mem_mb = stats[0].memory_used / (1024 * 1024)
    print(f"{container.name}: CPU={cpu:.1f}% MEM={mem_mb:.0f}MB")

This lets Python-based monitoring scripts watch resource consumption without external tools — useful for CI environments where Prometheus isn’t available.

The one thing to remember: Python + Docker Compose orchestration is most powerful as a programmable test and development environment — generating configs, controlling lifecycle, and verifying readiness through code rather than manual commands.

pythondockerorchestrationdevops

See Also