Module 2: Local & Container Deployment

2. Docker Compose for AI Apps

Overview

In this capsule you'll build a complete Docker Compose file for a multi-container AI app. Not a "hello world" with one service — a real system with FastAPI, Redis, and the structure a production AI app needs. By the end, you'll have a functional Compose you can adapt to any AI project.

Context: Docker Compose defines your infrastructure as code: what services run, how they communicate, what data persists. It's the "blueprint" of your system. In this guide, the Compose you build here is the base on which LocalStack (M4), migration patterns (M6), and the capstone project (M8) integrate.


Compose File: Complete Anatomy

Basic structure

# docker-compose.yml
# Each service is a container with its configuration

services:
  api:
    # Your FastAPI app — the entry point
    build: ./api
    ports:
      - "8000:8000"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - REDIS_URL=redis://cache:6379
    depends_on:
      cache:
        condition: service_healthy

  cache:
    # Redis — response cache
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  redis_data:

Each section explained

services:           # Defines the containers that make up your system
  api:              # Service name (also its hostname on the internal network)
    build: ./api    # Builds from the Dockerfile in ./api/
    ports:          # host:container mapping
    environment:    # Environment variables
    depends_on:     # Which services it needs before starting

  cache:
    image: redis:7-alpine  # Uses a pre-built image (doesn't build)
    volumes:        # Persistent data
    healthcheck:    # Health verification

volumes:            # Definition of persistent volumes
  redis_data:       # Redis data persists between restarts

The AI App: Complete Code

FastAPI with Redis cache

# api/main.py
import hashlib
import json
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from openai import OpenAI
import redis

app = FastAPI(title="AI API with Cache")

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
cache = redis.Redis.from_url(
    os.environ.get("REDIS_URL", "redis://localhost:6379"),
    decode_responses=True
)

class AskRequest(BaseModel):
    prompt: str
    max_tokens: int = 500
    use_cache: bool = True

class AskResponse(BaseModel):
    answer: str
    cached: bool
    tokens_used: int | None = None

@app.get("/health")
def health():
    redis_ok = False
    try:
        redis_ok = cache.ping()
    except redis.ConnectionError:
        pass

    return {
        "status": "healthy" if redis_ok else "degraded",
        "services": {
            "api": "up",
            "redis": "up" if redis_ok else "down",
        }
    }

@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest):
    cache_key = hashlib.md5(
        f"{request.prompt}:{request.max_tokens}".encode()
    ).hexdigest()

    if request.use_cache:
        cached_response = cache.get(cache_key)
        if cached_response:
            data = json.loads(cached_response)
            return AskResponse(answer=data["answer"], cached=True)

    try:
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": request.prompt}],
            max_tokens=request.max_tokens,
        )
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"LLM API error: {str(e)}")

    answer = response.choices[0].message.content
    tokens = response.usage.total_tokens

    cache.setex(
        cache_key,
        3600,  # TTL: 1 hour
        json.dumps({"answer": answer, "tokens": tokens})
    )

    return AskResponse(answer=answer, cached=False, tokens_used=tokens)

Dockerfile for the API

# api/Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Requirements

# api/requirements.txt
fastapi==0.115.0
uvicorn==0.30.0
openai>=1.0.0
redis==5.0.0
pydantic>=2.0.0

Complete Compose: 3 Services

# docker-compose.yml
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - REDIS_URL=redis://cache:6379
      - ENVIRONMENT=${ENVIRONMENT:-development}
    depends_on:
      cache:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  redis-commander:
    image: rediscommander/redis-commander:latest
    environment:
      - REDIS_HOSTS=local:cache:6379
    ports:
      - "8081:8081"
    depends_on:
      cache:
        condition: service_healthy
    profiles:
      - debug

volumes:
  redis_data:

Bring it up and test

# Bring up the main services
docker compose up -d

# Expected output:
# ✔ Network module-02_default  Created
# ✔ Container module-02-cache-1  Healthy
# ✔ Container module-02-api-1    Started

# Verify health
curl http://localhost:8000/health
# {"status":"healthy","services":{"api":"up","redis":"up"}}

# Test the ask endpoint
curl -X POST http://localhost:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"prompt": "What is Docker Compose in one line?"}'
# {"answer":"Docker Compose is...","cached":false,"tokens_used":45}

# Second request (cached)
curl -X POST http://localhost:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"prompt": "What is Docker Compose in one line?"}'
# {"answer":"Docker Compose is...","cached":true,"tokens_used":null}

# Bring up with debug tools
docker compose --profile debug up -d
# Now redis-commander is available at http://localhost:8081

Common Compose Patterns for AI

Pattern 1: API + Cache

The most basic and most common. FastAPI + Redis. Covers 70% of AI apps.

Pattern 2: API + Cache + Vector Store

For RAG apps that need semantic search:

services:
  api:
    build: ./api
    ports:
      - "8000:8000"
    depends_on:
      cache:
        condition: service_healthy
      vectordb:
        condition: service_healthy

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]

  vectordb:
    image: qdrant/qdrant:latest
    ports:
      - "6333:6333"
    volumes:
      - qdrant_data:/qdrant/storage
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
      interval: 10s

volumes:
  qdrant_data:

Pattern 3: API + Cache + Worker (async)

For processing that doesn't fit in the request/response cycle (long documents, embedding batches):

services:
  api:
    build: ./api
    ports:
      - "8000:8000"
    environment:
      - REDIS_URL=redis://cache:6379

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]

  worker:
    build: ./worker
    environment:
      - REDIS_URL=redis://cache:6379
      - OPENAI_API_KEY=${OPENAI_API_KEY}
    depends_on:
      cache:
        condition: service_healthy
    # The worker reads jobs from Redis and processes them in the background
    # The API publishes jobs, the worker consumes them

Pattern comparison

PatternServicesWhen to use itComplexity
API + Cache2Chatbot, Q&A, assistantsLow
API + Cache + VectorDB3RAG, semantic searchMedium
API + Cache + Worker3Document processing, batchMedium
API + Cache + VectorDB + Worker4+Complete AI systemHigh

Recommendation: Start with Pattern 1 (API + Cache). Add services when you need them, not before. The Compose file is easy to extend — easier than refactoring a monolith.

Advanced pattern: With Nginx as a reverse proxy

For production or when you scale the API:

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      api:
        condition: service_healthy

  api:
    build: ./api
    expose:
      - "8000"  # Only visible internally
    # ...rest of the config

This prepares your Compose for capsule 05 (Networking), where you'll go deep on reverse proxy, SSL, and isolated networks.


Comparison: Docker Run vs Docker Compose

Aspectdocker rundocker compose
Services1 containerN containers
NetworkingManual (--network)Automatic
VolumesManual (-v)Declarative
DependenciesNot manageddepends_on + healthcheck
ReproducibilityLong commandsOne YAML file
ScalingManualdocker compose up --scale api=3

Troubleshooting

Problem 1: "The api service doesn't connect to Redis"

# Verify that Redis is running
docker compose ps
# cache should be "Up (healthy)"

# Verify networking
docker compose exec api ping cache
# It should resolve to Redis's internal IP

# Verify the environment variable
docker compose exec api env | grep REDIS
# REDIS_URL=redis://cache:6379

Problem 2: "The build takes too long"

# Optimize the Dockerfile with layer caching
# BEFORE (rebuild everything when code changes):
COPY . .
RUN pip install -r requirements.txt

# AFTER (only rebuild if requirements change):
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

Problem 3: "The OpenAI API key doesn't reach the container"

# Verify that the variable is defined in your shell
echo $OPENAI_API_KEY
# If it's empty, Compose passes an empty variable to the container

# Verify that .env exists and has the variable
cat .env | grep OPENAI
# OPENAI_API_KEY=sk-proj-...

# Verify inside the container
docker compose exec api env | grep OPENAI
# If it doesn't appear, check that your docker-compose.yml has:
# environment:
#   - OPENAI_API_KEY=${OPENAI_API_KEY}

Problem 4: "Redis data is lost on restart"

Make sure you have a volume defined and appendonly yes:

cache:
  image: redis:7-alpine
  command: redis-server --appendonly yes
  volumes:
    - redis_data:/data  # Persistence

volumes:
  redis_data:  # Must be declared

Problem 5: "Container restarts in a loop (restart: always)"

# Check the restart count
docker compose ps
# If you see "Restarting (1)" repeatedly:

# 1. See the crash logs
docker compose logs api --tail=50
# Look for the error causing the exit

# 2. Temporarily disable restart to see the error
# Change restart: unless-stopped → restart: "no"
# Bring it up again and read the full error log

# 3. Common causes in AI apps:
# - Empty OPENAI_API_KEY → the app validates and fails
# - Redis not ready → ConnectionRefusedError on startup
# - Port already in use → bind: address already in use

Hands-On Exercises

Exercise 1: Add a health monitoring service

Add a service that every 60 seconds curls the API's /health endpoint and logs the result.

See solution
  health-monitor:
    image: alpine/curl
    command: >
      sh -c "while true; do
        echo \"$(date): $(curl -s http://api:8000/health)\";
        sleep 60;
      done"
    depends_on:
      api:
        condition: service_healthy

Exercise 2: Add Qdrant as a vector store

Extend the Compose to include Qdrant and make the API depend on it.

See solution
  vectordb:
    image: qdrant/qdrant:latest
    ports:
      - "6333:6333"
    volumes:
      - qdrant_data:/qdrant/storage
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
      interval: 10s
      timeout: 5s
      retries: 3

# Add to api:
  api:
    depends_on:
      cache:
        condition: service_healthy
      vectordb:
        condition: service_healthy
    environment:
      - QDRANT_URL=http://vectordb:6333

# Add at the end:
volumes:
  redis_data:
  qdrant_data:

Exercise 3: Scaling the API

Bring up 3 instances of the API and verify that they're all healthy.

See solution
# First, remove the fixed port mapping (you can't have 3 on port 8000)
# In docker-compose.yml, change ports to expose:
#   api:
#     expose:
#       - "8000"
#     # Remove ports: - "8000:8000"

# Then scale
docker compose up -d --scale api=3

# Verify
docker compose ps
# It should show 3 instances of api, all healthy

# To access them, you need a load balancer (Nginx) in front
# That's advanced — for now, verify that all 3 start

Exercise 4: Docker Compose with profiles

Create a "dev" profile that includes redis-commander and hot reload, and a "prod" profile that doesn't include them.

See solution
services:
  api:
    build: ./api
    # In dev: hot reload with volume mount
    volumes:
      - ./api:/app  # Dev only
    profiles:
      - dev
      - prod

  api-prod:
    build: ./api
    # No volume mount, no reload
    profiles:
      - prod

  cache:
    image: redis:7-alpine
    # Always present (no profile = all profiles)

  redis-commander:
    image: rediscommander/redis-commander:latest
    profiles:
      - dev  # Dev only
docker compose --profile dev up -d     # Dev with debug tools
docker compose --profile prod up -d    # Prod without extras

Summary

  • Docker Compose defines your infrastructure as code: services, networks, volumes.
  • For AI apps, the base pattern is FastAPI + Redis (response cache).
  • depends_on with condition: service_healthy ensures services start in order.
  • Volumes persist data between restarts (Redis data, vector stores).
  • Profiles separate dev and prod configuration.
  • The Compose you build here is the base for M4, M6, and M8.

Additional Resources

  1. Docker Compose Specification — Official format reference
  2. Compose Deploy Specification — Deployment config
  3. Redis Docker Official Image — Redis image documentation
  4. Qdrant Docker — Qdrant on Docker
  5. FastAPI with Docker — Deploy FastAPI on Docker
  6. Docker Compose Networking — Networking between services