Module 2: Local & Container Deployment
8. Project: Local Multi-Container AI App
Project Overview
This is the capstone project of Module 2. You'll build a complete multi-container AI app with Docker Compose: FastAPI as the API gateway, Redis as the response cache, per-environment configuration, functional health checks, and a documented debugging flow. The result is a local system that replicates production.
Why it matters: This Compose is the base artifact for the rest of the guide. In Module 4, you'll add LocalStack as a service. In Module 6, it'll be your development environment while you migrate to AWS. And in Module 8, it'll be the starting point of the Docker → CI/CD → platform pipeline.
Project Objective
Produce a functional Docker Compose that:
- Brings up a multi-container AI app with
docker compose up - Includes FastAPI (API), Redis (cache), and health checks
- Has per-environment configuration (dev, staging, prod)
- Handles secrets safely
- Caches LLM responses to reduce costs
- Can be debugged with the module's systematic flow
Module Recap
| Capsule | Concept | You use it in the project |
|---|---|---|
| 02 | Docker Compose multi-service | Base structure of the project |
| 03 | Environment configuration | .env files, overrides |
| 04 | Health checks and dependencies | Verified readiness |
| 05 | Networking and communication | Connected services |
| 06 | Debugging | Diagnostic flow |
| 07 | Secrets and security | Protected API keys |
Technical Specifications
Architecture
┌─────────────┐
HTTP :8000 ───→ │ FastAPI │
│ (api) │
└──────┬──────┘
│
┌──────┴──────┐
│ │
┌─────┴─────┐ ┌────┴─────┐
│ Redis │ │ OpenAI │
│ (cache) │ │ API │
└───────────┘ └──────────┘
Required services
| Service | Image/Build | Port | Health check |
|---|---|---|---|
| api | Build from ./api | 8000 (host) | GET /health |
| cache | redis:7-alpine | 6379 (internal) | redis-cli ping |
| redis-commander | rediscommander/redis-commander | 8081 (debug only) | profile: debug |
Required endpoints
GET /health → Status of all services
GET /health/detailed → Latency of each dependency
POST /ask → Ask the LLM (with cache)
GET /cache/stats → Cache hit rate
DELETE /cache → Clear the cache
Detailed requirements
API (FastAPI):
- Must start only when Redis is healthy (
depends_onwithcondition: service_healthy) - The
/askendpoint must try the cache first, calling OpenAI only on a cache miss - If Redis is down,
/askmust keep working (graceful degradation) — call OpenAI directly - The health check must report the individual status of each dependency
- OpenAI API errors must return HTTP 502 with a descriptive message
- Logging configured by environment variable (
LOG_LEVEL)
Cache (Redis):
- Configured with
maxmemoryand theallkeys-lrupolicy (so it doesn't grow without limit) - Data persisted with
appendonly yes - Configurable TTL via environment variable (
CACHE_TTL)
Configuration:
- All sensitive variables in
.env(never in code) .env.examplewith placeholders for onboardingdocker-compose.override.ymlfor development (hot reload, debug logging)docker-compose.prod.ymlfor production (multiple workers, warning logging, resource limits)- Pydantic Settings validates that
OPENAI_API_KEYexists and isn't a placeholder
Debugging:
- A
debug.shscript that runs a 5-step diagnosis - redis-commander available with the
debugprofile
Required files
module-02-project/
├── api/
│ ├── main.py # Complete FastAPI app
│ ├── config.py # Settings with Pydantic
│ ├── requirements.txt # Dependencies
│ └── Dockerfile # Multi-stage or slim
├── docker-compose.yml # Base config
├── docker-compose.override.yml # Dev overrides (hot reload)
├── docker-compose.prod.yml # Production overrides
├── .env # Dev variables (NOT in Git)
├── .env.example # Template (DO put in Git)
├── .gitignore # Ignores .env and secrets
├── .dockerignore # Ignores .env in the build
└── debug.sh # Diagnostic script
Evaluation Rubric (100 points)
Functionality (40 points)
| Criterion | Points | How it's evaluated |
|---|---|---|
docker compose up brings up all services without errors | 8 | Run and verify docker compose ps |
/health returns the correct status of api and redis | 6 | curl /health shows both services |
/health/detailed shows dependency latency | 4 | curl /health/detailed includes latency_ms |
/ask invokes the LLM and returns a response | 8 | POST with a prompt returns answer |
Cache works: an identical second request returns cached: true | 8 | Repeat the request, verify the cached field |
/cache/stats shows hit rate and memory | 3 | curl /cache/stats returns metrics |
DELETE /cache clears the cache | 3 | DELETE + repeat request = cached: false |
Configuration (25 points)
| Criterion | Points | How it's evaluated |
|---|---|---|
.env is not in Git; .env.example is | 5 | git status, verify .gitignore |
.dockerignore excludes .env and sensitive files | 3 | cat .dockerignore |
Pydantic Settings validates OPENAI_API_KEY | 5 | Start without the key → clear error |
docker-compose.override.yml with hot reload | 4 | Edit code → reflected without a rebuild |
docker-compose.prod.yml with workers and resource limits | 4 | docker compose -f ... config shows workers |
| Configurable environment variables (LOG_LEVEL, CACHE_TTL, etc.) | 4 | Change in .env, restart, verify behavior |
Debugging (20 points)
| Criterion | Points | How it's evaluated |
|---|---|---|
debug.sh runs the 5 diagnostic steps | 8 | bash debug.sh produces useful output |
| Functional health checks (api and cache) | 4 | docker compose ps shows (healthy) |
| Graceful degradation: app works without Redis | 4 | docker compose stop cache, then /ask keeps working |
| redis-commander accessible with the debug profile | 4 | docker compose --profile debug up -d, open :8081 |
Documentation (15 points)
| Criterion | Points | How it's evaluated |
|---|---|---|
.env.example complete with all variables | 5 | Compare variables in code vs .env.example |
| Clear setup instructions (README or comments) | 5 | Someone new can bring up the project following the instructions |
| Commented code where it's not obvious (config, health checks) | 5 | Read the code, understand without external explanation |
Grading scale
| Range | Grade |
|---|---|
| 90-100 | Excellent — production-ready |
| 75-89 | Good — functional with minor improvements |
| 60-74 | Acceptable — works but lacks robustness |
| < 60 | Needs work — review the module capsules |
Minimal Implementation Example
This is the minimal example that passes the rubric (≥60 points). Your implementation should be better than this.
api/config.py (minimal)
import os
from pydantic_settings import BaseSettings
from pydantic import field_validator
class Settings(BaseSettings):
openai_api_key: str
redis_url: str = "redis://cache:6379"
environment: str = "development"
log_level: str = "debug"
cache_ttl: int = 3600
model_name: str = "gpt-4o-mini"
max_tokens: int = 500
@field_validator("openai_api_key")
@classmethod
def validate_key(cls, v):
if not v or "replace" in v.lower():
raise ValueError("Set a real OPENAI_API_KEY in .env")
return v
class Config:
env_file = ".env"
settings = Settings()
api/main.py (minimal)
import hashlib
import json
import logging
import time
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from openai import OpenAI
import redis
from config import settings
logging.basicConfig(level=getattr(logging, settings.log_level.upper()))
logger = logging.getLogger(__name__)
app = FastAPI(title="AI API", version="1.0.0")
client = OpenAI(api_key=settings.openai_api_key, timeout=30.0)
cache = redis.Redis.from_url(settings.redis_url, 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
model: str = ""
@app.get("/health")
def health():
checks = {"api": "up"}
try:
cache.ping()
checks["redis"] = "up"
except Exception:
checks["redis"] = "down"
overall = "healthy" if all(v == "up" for v in checks.values()) else "degraded"
status_code = 200 if overall == "healthy" else 503
return JSONResponse(
status_code=status_code,
content={"status": overall, "environment": settings.environment, "services": checks}
)
@app.get("/health/detailed")
def health_detailed():
checks = {}
start = time.time()
try:
cache.ping()
checks["redis"] = {"status": "up", "latency_ms": round((time.time() - start) * 1000, 1)}
except Exception as e:
checks["redis"] = {"status": "down", "error": str(e)}
start = time.time()
try:
client.chat.completions.create(
model=settings.model_name,
messages=[{"role": "user", "content": "ping"}],
max_tokens=5
)
checks["openai"] = {"status": "up", "latency_ms": round((time.time() - start) * 1000, 1)}
except Exception as e:
checks["openai"] = {"status": "down", "error": str(e)}
overall = "healthy" if all(c.get("status") == "up" for c in checks.values()) else "degraded"
return {"status": overall, "checks": checks}
@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest):
cache_key = f"ask:{hashlib.md5(f'{request.prompt}:{request.max_tokens}'.encode()).hexdigest()}"
if request.use_cache:
try:
cached = cache.get(cache_key)
if cached:
data = json.loads(cached)
return AskResponse(answer=data["answer"], cached=True, model=data.get("model", ""))
except redis.ConnectionError:
logger.warning("Redis unavailable, proceeding without cache")
try:
response = client.chat.completions.create(
model=settings.model_name,
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
try:
cache.setex(
cache_key, settings.cache_ttl,
json.dumps({"answer": answer, "tokens": tokens, "model": settings.model_name})
)
except redis.ConnectionError:
logger.warning("Redis unavailable, response not cached")
return AskResponse(answer=answer, cached=False, tokens_used=tokens, model=settings.model_name)
@app.get("/cache/stats")
def cache_stats():
try:
info = cache.info()
return {
"hits": info.get("keyspace_hits", 0),
"misses": info.get("keyspace_misses", 0),
"hit_rate": round(
info.get("keyspace_hits", 0) / max(1, info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0)) * 100, 1
),
"keys": cache.dbsize(),
"memory_used": info.get("used_memory_human", "unknown"),
}
except redis.ConnectionError:
return {"error": "Redis unavailable"}
@app.delete("/cache")
def clear_cache():
try:
cache.flushdb()
return {"status": "cache cleared"}
except redis.ConnectionError:
raise HTTPException(status_code=503, detail="Redis unavailable")
api/requirements.txt
fastapi==0.115.0
uvicorn==0.30.0
openai>=1.0.0
redis==5.0.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
api/Dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
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"]
Complete Code (Reference Implementation)
This reference implementation includes everything needed to score 90+ points.
api/config.py
import os
from pydantic_settings import BaseSettings
from pydantic import field_validator
class Settings(BaseSettings):
openai_api_key: str
redis_url: str = "redis://cache:6379"
environment: str = "development"
log_level: str = "debug"
cache_ttl: int = 3600
model_name: str = "gpt-4o-mini"
max_tokens: int = 500
@field_validator("openai_api_key")
@classmethod
def validate_key(cls, v):
if not v or "replace" in v.lower():
raise ValueError("Set a real OPENAI_API_KEY in .env")
return v
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v):
valid = {"debug", "info", "warning", "error", "critical"}
if v.lower() not in valid:
raise ValueError(f"LOG_LEVEL must be one of: {valid}")
return v.lower()
@property
def is_dev(self) -> bool:
return self.environment == "development"
class Config:
env_file = ".env"
settings = Settings()
api/main.py
import hashlib
import json
import logging
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from openai import OpenAI
import redis
from config import settings
logging.basicConfig(level=getattr(logging, settings.log_level.upper()))
logger = logging.getLogger(__name__)
client = OpenAI(api_key=settings.openai_api_key, timeout=30.0, max_retries=2)
cache = redis.Redis.from_url(settings.redis_url, decode_responses=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
cache.ping()
logger.info("Redis connection established")
except redis.ConnectionError:
logger.warning("Redis not available at startup — will retry on requests")
yield
app = FastAPI(title="AI API", version="1.0.0", lifespan=lifespan)
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
model: str = ""
@app.get("/health")
def health():
checks = {"api": "up"}
try:
cache.ping()
checks["redis"] = "up"
except Exception:
checks["redis"] = "down"
overall = "healthy" if all(v == "up" for v in checks.values()) else "degraded"
status_code = 200 if overall == "healthy" else 503
return JSONResponse(
status_code=status_code,
content={"status": overall, "environment": settings.environment, "services": checks}
)
@app.get("/health/detailed")
def health_detailed():
checks = {}
start = time.time()
try:
cache.ping()
checks["redis"] = {"status": "up", "latency_ms": round((time.time() - start) * 1000, 1)}
except Exception as e:
checks["redis"] = {"status": "down", "error": str(e)}
start = time.time()
try:
r = client.chat.completions.create(
model=settings.model_name,
messages=[{"role": "user", "content": "ping"}],
max_tokens=5
)
checks["openai"] = {"status": "up", "latency_ms": round((time.time() - start) * 1000, 1)}
except Exception as e:
checks["openai"] = {"status": "down", "error": str(e)}
overall = "healthy" if all(c.get("status") == "up" for c in checks.values()) else "degraded"
return {"status": overall, "checks": checks}
@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest):
cache_key = f"ask:{hashlib.md5(f'{request.prompt}:{request.max_tokens}'.encode()).hexdigest()}"
if request.use_cache:
try:
cached = cache.get(cache_key)
if cached:
data = json.loads(cached)
logger.debug(f"Cache hit for key {cache_key[:8]}")
return AskResponse(answer=data["answer"], cached=True, model=data.get("model", ""))
except redis.ConnectionError:
logger.warning("Redis unavailable, proceeding without cache")
try:
response = client.chat.completions.create(
model=settings.model_name,
messages=[{"role": "user", "content": request.prompt}],
max_tokens=request.max_tokens,
)
except Exception as e:
logger.error(f"OpenAI API error: {e}")
raise HTTPException(status_code=502, detail=f"LLM API error: {str(e)}")
answer = response.choices[0].message.content
tokens = response.usage.total_tokens
try:
cache.setex(
cache_key, settings.cache_ttl,
json.dumps({"answer": answer, "tokens": tokens, "model": settings.model_name})
)
except redis.ConnectionError:
logger.warning("Redis unavailable, response not cached")
return AskResponse(answer=answer, cached=False, tokens_used=tokens, model=settings.model_name)
@app.get("/cache/stats")
def cache_stats():
try:
info = cache.info()
return {
"hits": info.get("keyspace_hits", 0),
"misses": info.get("keyspace_misses", 0),
"hit_rate": round(
info.get("keyspace_hits", 0) / max(1, info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0)) * 100, 1
),
"keys": cache.dbsize(),
"memory_used": info.get("used_memory_human", "unknown"),
}
except redis.ConnectionError:
return {"error": "Redis unavailable"}
@app.delete("/cache")
def clear_cache():
try:
cache.flushdb()
return {"status": "cache cleared"}
except redis.ConnectionError:
raise HTTPException(status_code=503, detail="Redis unavailable")
api/requirements.txt
fastapi==0.115.0
uvicorn==0.30.0
openai>=1.0.0
redis==5.0.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
api/Dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
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"]
docker-compose.yml
services:
api:
build:
context: ./api
ports:
- "${API_PORT:-8000}:8000"
env_file:
- .env
environment:
- REDIS_URL=redis://cache:6379
depends_on:
cache:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
restart: unless-stopped
cache:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
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:
docker-compose.override.yml (dev)
services:
api:
volumes:
- ./api:/app
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
environment:
- LOG_LEVEL=debug
docker-compose.prod.yml
services:
api:
command: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
environment:
- LOG_LEVEL=warning
- ENVIRONMENT=production
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
reservations:
memory: 256M
.env.example
OPENAI_API_KEY=sk-proj-replace-with-your-key
ENVIRONMENT=development
LOG_LEVEL=debug
CACHE_TTL=3600
MODEL_NAME=gpt-4o-mini
MAX_TOKENS=500
API_PORT=8000
.gitignore
.env
.env.production
.env.staging
!.env.example
secrets/
__pycache__/
*.pyc
.dockerignore
.env
.env.*
!.env.example
secrets/
.git/
__pycache__/
*.pyc
.gitignore
debug.sh
docker-compose*.yml
debug.sh
#!/bin/bash
echo "=== Service Status ==="
docker compose ps
echo -e "\n=== Health Check ==="
curl -s http://localhost:8000/health | python3 -m json.tool 2>/dev/null || echo "API not responding"
echo -e "\n=== Detailed Health ==="
curl -s http://localhost:8000/health/detailed | python3 -m json.tool 2>/dev/null || echo "Detailed health unavailable"
echo -e "\n=== Cache Stats ==="
curl -s http://localhost:8000/cache/stats | python3 -m json.tool 2>/dev/null || echo "Stats unavailable"
echo -e "\n=== Resource Usage ==="
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
echo -e "\n=== Recent Logs (errors only) ==="
docker compose logs --tail 20 2>&1 | grep -i "error\|exception\|failed" || echo "No errors found"
echo -e "\n=== Config Validation ==="
docker compose config --quiet && echo "✅ Config OK" || echo "❌ Config ERROR"
Step by Step to Build It
1. Create the structure (2 min)
mkdir -p module-02-project/api
cd module-02-project
2. Create the code files (10 min)
Copy the files from the previous sections: config.py, main.py, requirements.txt, Dockerfile.
3. Create the Docker Compose files (5 min)
Copy docker-compose.yml, docker-compose.override.yml, docker-compose.prod.yml.
4. Create the security files (2 min)
Copy .gitignore, .dockerignore, .env.example.
5. Configure the environment (2 min)
cp .env.example .env
# Edit .env with your real OPENAI_API_KEY
6. Bring it up and test (5 min)
docker compose up -d
# Wait for the health checks to pass
# Test health
curl http://localhost:8000/health
# Test ask
curl -X POST http://localhost:8000/ask \
-H "Content-Type: application/json" \
-d '{"prompt": "What is Docker Compose?"}'
# Test cache (repeat the same request)
curl -X POST http://localhost:8000/ask \
-H "Content-Type: application/json" \
-d '{"prompt": "What is Docker Compose?"}'
# Should return cached: true
# Cache stats
curl http://localhost:8000/cache/stats
7. Test per-environment configuration (3 min)
# Production mode
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
curl http://localhost:8000/health
# environment: "production" if you configured .env.production
8. Test debugging (3 min)
chmod +x debug.sh
bash debug.sh
# Test redis-commander
docker compose --profile debug up -d
# Open http://localhost:8081 in the browser
9. Test graceful degradation (3 min)
# Stop Redis
docker compose stop cache
# Verify that the API still responds
curl http://localhost:8000/health
# {"status":"degraded","services":{"api":"up","redis":"down"}}
curl -X POST http://localhost:8000/ask \
-H "Content-Type: application/json" \
-d '{"prompt": "test without cache"}'
# Should work (without cache, straight to OpenAI)
# Restore Redis
docker compose start cache
Completeness Checklist
Functionality
-
docker compose upbrings up all services without errors -
/healthreturns the status of api and redis -
/health/detailedshows the latency of each dependency -
/askinvokes the LLM and returns a response - An identical second request returns
cached: true -
/cache/statsshows hit rate and memory -
DELETE /cacheclears the cache correctly
Configuration
-
.envis not in Git;.env.exampleis -
.dockerignoreexcludes.envand sensitive files - Settings config validates that OPENAI_API_KEY exists
- Health checks verify real readiness
- Compose works with override (dev) and prod
Debugging
-
debug.shruns without errors and produces useful output - redis-commander accessible with
--profile debug - App works in degraded mode without Redis
Documentation
-
.env.examplehas all the necessary variables - Code has comments where it's not obvious
Project Troubleshooting
"docker compose up fails with 'openai_api_key is required'"
Your .env doesn't have OPENAI_API_KEY or it's a placeholder. Copy .env.example to .env and fill it with your real key.
cp .env.example .env
# Edit .env: OPENAI_API_KEY=sk-proj-your-real-key
docker compose up -d
"The first request is very slow"
Normal in dev with hot reload — uvicorn reloads modules on the first request. In prod (without --reload), it's faster. The API's cold start is independent of Lambda's cold start.
# Verify response time
time curl -s -X POST http://localhost:8000/ask \
-H "Content-Type: application/json" \
-d '{"prompt":"ping"}'
# First request: 3-10s (normal, includes OpenAI cold start)
# Following requests (cached): <100ms
"redis-commander doesn't appear"
You need the debug profile: docker compose --profile debug up -d
# Verify that the container is running
docker compose --profile debug ps
# redis-commander should be Up
# Verify the port
curl -s http://localhost:8081 | head -5
# Should return HTML
"Cache doesn't work — always returns cached: false"
# Verify that Redis is healthy
docker compose ps cache
# Should be: Up (healthy)
# Verify connectivity from the API
docker compose exec api python -c "
import redis
r = redis.Redis.from_url('redis://cache:6379', decode_responses=True)
r.set('test', 'hello')
print(r.get('test')) # Should print: hello
"
# Verify that CACHE_TTL isn't 0
docker compose exec api env | grep CACHE_TTL
"Exit code 137 on heavy requests"
Your container is running out of memory. Increase the limit or reduce usage:
# See current memory usage
docker stats --no-stream
# If you're using docker-compose.prod.yml with memory: 512M
# and your app needs more, increase it:
# deploy.resources.limits.memory: 1G
# Or run without limits for dev:
docker compose up -d # uses override.yml with no limits
"docker compose build takes too long"
The first build downloads the base image and the dependencies. The following ones are faster thanks to layer caching:
# If you need to force a clean rebuild:
docker compose build --no-cache api
# Tip: separate COPY requirements.txt and pip install BEFORE COPY . .
# That way pip install only re-runs if requirements.txt changed
Connection to the Guide
This Docker Compose is your base artifact. In the coming modules:
- M3 (Lambda): You'll learn the serverless alternative
- M4 (LocalStack): You'll add LocalStack as a service in this Compose
- M6 (Migration): This Compose will be your development environment
- M8 (Capstone): This Compose is the starting point for the deploy to production
Summary
- The project integrates all the capsules of Module 2: Compose, env config, health checks, networking, debugging, and secrets.
- The architecture is FastAPI + Redis + OpenAI API — the base stack for AI apps with cache.
- Graceful degradation is key: if Redis goes down, the app keeps working (without cache).
- Per-environment configuration (
override.ymlfor dev,prod.ymlfor production) reflects how real teams work. - Pydantic Settings validates secrets at startup — it never starts with invalid configuration.
- The
debug.shscript runs the 5 diagnostic steps in one command. - This Compose is your base artifact for the rest of the guide — you'll reuse it in modules 4, 6, and 8.
Resources for the Project
- Docker Compose File Reference — Complete specification
- FastAPI Production Deployment — Official deployment guide
- Redis Configuration — Redis configuration
- Uvicorn Deployment — Uvicorn deployment options
- Docker Best Practices — Dockerfile best practices
- Pydantic Settings — Configuration validation
- OpenAI Python SDK — Official OpenAI client