Docker Compose for Local AI Development
Build a complete local development environment for an AI service using Docker Compose — FastAPI, Redis, PostgreSQL, and a mock Azure OpenAI server — with hot reload, health checks, and env file management.
Why Docker Compose Is Essential for AI Development
Developing an AI service involves more than just one process. A realistic pharmabot-style service depends on:
- FastAPI — the application server
- Redis — for session caching and rate limiting
- PostgreSQL — for conversation history and user data
- A mock LLM server — so you don't burn through Azure OpenAI quota during development
Without Compose, you'd need to run four separate processes manually, remember their startup order, manage their network connectivity, and set up environment variables for each. Anyone joining the team has a half-day of setup ahead of them.
With Compose, the entire environment starts with a single command:
docker compose upAnd with the --watch flag (Compose v2.22+), your FastAPI code changes reload automatically without restarting the entire stack.
Project Structure
pharmabot/
├── app/
│ ├── main.py
│ ├── config.py
│ ├── routers/
│ │ └── chat.py
│ └── services/
│ └── llm_service.py
├── mock-openai/
│ └── mappings/
│ ├── chat-completions.json
│ └── health.json
├── docker-compose.yml
├── docker-compose.override.yml ← dev overrides (not committed)
├── Dockerfile
├── .env.local ← gitignored local secrets
└── .env.example ← committed, shows required varsThe .env.local File
Never commit real secrets. Instead, commit a .env.example that documents every required variable:
# .env.example — commit this
AZURE_OPENAI_ENDPOINT=https://YOUR_RESOURCE.openai.azure.com/
AZURE_OPENAI_API_KEY=your-key-here
AZURE_OPENAI_DEPLOYMENT=gpt-4o
POSTGRES_PASSWORD=changeme
REDIS_PASSWORD=changeme
DEBUG=trueDevelopers copy this to .env.local and fill in real values (or use the mock values for local dev):
# .env.local — gitignored
AZURE_OPENAI_ENDPOINT=http://mock-openai:8080
AZURE_OPENAI_API_KEY=fake-key-for-local-dev
AZURE_OPENAI_DEPLOYMENT=gpt-4o
POSTGRES_PASSWORD=localdevpassword
REDIS_PASSWORD=localdevpassword
DEBUG=trueFor local development, AZURE_OPENAI_ENDPOINT points to http://mock-openai:8080 — the mock server defined in Compose. The app code doesn't change; only the endpoint variable does.
The Mock Azure OpenAI Server (WireMock)
WireMock is a flexible HTTP mock server that serves pre-recorded or configured responses. We'll use it to simulate Azure OpenAI's Chat Completions API.
Why mock Azure OpenAI?
- No API key needed for onboarding new developers
- Deterministic responses — tests don't flake due to model randomness
- Instant responses — no network latency, no cold starts
- No cost — zero tokens consumed during development
WireMock uses JSON "mapping" files to define request/response pairs. Create mock-openai/mappings/chat-completions.json:
{
"mappings": [
{
"request": {
"method": "POST",
"urlPathPattern": "/openai/deployments/gpt-4o/chat/completions.*"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"id": "chatcmpl-mock-001",
"object": "chat.completion",
"created": 1715789000,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "This is a mock response from the local WireMock server. The real Azure OpenAI service would respond here with medically relevant information."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 42,
"completion_tokens": 30,
"total_tokens": 72
}
}
}
},
{
"request": {
"method": "GET",
"url": "/"
},
"response": {
"status": 200,
"body": "WireMock mock Azure OpenAI server running"
}
}
]
}WireMock runs as a Docker container and serves this mapping without any code changes to your app.
The Complete docker-compose.yml
# docker-compose.yml
# Requires: Docker Compose v2.20+ (ships with Docker Desktop 4.22+)
name: pharmabot
services:
# ── Application server ─────────────────────────────────────────────────────
web:
build:
context: .
dockerfile: Dockerfile
target: development # Uses the development stage (with hot reload)
ports:
- "8000:8000"
env_file:
- .env.local
environment:
# These override .env.local values or add extras specific to Compose
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
DATABASE_URL: postgresql://pharmabot:${POSTGRES_PASSWORD}@postgres:5432/pharmabot
PYTHONPATH: /app
volumes:
# Mount source code for hot reload
# Changes to ./app are reflected immediately in the container
- ./app:/app/app:delegated
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
mock-openai:
condition: service_started
develop:
# Compose Watch configuration (docker compose up --watch)
# Syncs file changes without full container restart
watch:
- action: sync
path: ./app
target: /app/app
- action: rebuild
path: requirements.txt
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
networks:
- pharmabot-net
restart: unless-stopped
# ── Redis (session cache, rate limiting) ───────────────────────────────────
redis:
image: redis:7.2-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
ports:
- "6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
networks:
- pharmabot-net
restart: unless-stopped
# ── PostgreSQL (conversation history, users) ───────────────────────────────
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: pharmabot
POSTGRES_USER: pharmabot
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pharmabot -d pharmabot"]
interval: 5s
timeout: 5s
retries: 10
start_period: 15s
networks:
- pharmabot-net
restart: unless-stopped
# ── Mock Azure OpenAI Server (WireMock) ────────────────────────────────────
mock-openai:
image: wiremock/wiremock:3.5.4
command:
- --port=8080
- --verbose
- --global-response-templating
ports:
- "8080:8080"
volumes:
# Mount our mapping files into the WireMock mappings directory
- ./mock-openai/mappings:/home/wiremock/mappings:ro
networks:
- pharmabot-net
restart: unless-stopped
# ── pgAdmin (database GUI — optional, dev only) ────────────────────────────
pgadmin:
image: dpage/pgadmin4:8.6
profiles:
- tools # Only starts when you run: docker compose --profile tools up
environment:
PGADMIN_DEFAULT_EMAIL: admin@pharmabot.local
PGADMIN_DEFAULT_PASSWORD: admin
ports:
- "5050:80"
depends_on:
postgres:
condition: service_healthy
networks:
- pharmabot-net
# ── Volumes ───────────────────────────────────────────────────────────────────
volumes:
redis-data:
driver: local
postgres-data:
driver: local
# ── Networks ──────────────────────────────────────────────────────────────────
networks:
pharmabot-net:
driver: bridgedepends_on vs depends_on with condition: service_healthy
This is one of the most misunderstood parts of Docker Compose. There are two forms:
Form 1: Simple depends_on (starts, does not wait for ready)
depends_on:
- postgresThis tells Compose to start postgres before web. But "started" means the container process launched, not that PostgreSQL is ready to accept connections. If web tries to connect during the 5–10 seconds PostgreSQL takes to initialise, it fails.
Form 2: depends_on with condition: service_healthy (waits for ready)
depends_on:
postgres:
condition: service_healthyThis tells Compose to wait until the postgres service passes its healthcheck before starting web. This is the correct approach for databases and caches that need initialisation time.
For it to work, the dependency service must have a healthcheck defined:
postgres:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pharmabot -d pharmabot"]
interval: 5s
timeout: 5s
retries: 10
start_period: 15sThe start_period parameter is critical for databases: it gives the container a grace period before health check failures start counting toward retries. Without it, the first few checks during PostgreSQL startup fail immediately and the container is marked unhealthy before it's had a chance to start.
Condition options:
| Condition | Meaning |
|---|---|
| service_started | Container process has started (default for simple form) |
| service_healthy | Container is passing its healthcheck |
| service_completed_successfully | Container exited with code 0 (for init containers) |
Use service_healthy for databases, caches, and any service that needs time to initialise.
Volume Mounts for Hot Reload
volumes:
- ./app:/app/app:delegatedThis mounts your local ./app directory to /app/app inside the container. When you save a file in VS Code, the change appears immediately in the container's filesystem.
The :delegated flag is a macOS optimisation — it allows the container to cache writes slightly before syncing to the host. On Windows (WSL2) and Linux, it's ignored but harmless.
For hot reload to work, your Dockerfile's development stage must run uvicorn with --reload:
FROM python:3.11-slim AS development
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
# No COPY app/ here — it's mounted by Compose
CMD ["uvicorn", "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"]Never use --reload in production. It watches the filesystem for changes, which is unnecessary overhead and a potential security concern in a production container.
Running the Stack
Start the full development stack:
docker compose upStart with Compose Watch (auto-sync file changes without restart):
docker compose up --watchStart in detached mode (background):
docker compose up -dStart with the tools profile (includes pgAdmin):
docker compose --profile tools upCheck what's running:
docker compose psExpected output:
NAME IMAGE STATUS
pharmabot-web-1 pharmabot-web Up (healthy)
pharmabot-redis-1 redis:7.2-alpine Up (healthy)
pharmabot-postgres-1 postgres:16-alpine Up (healthy)
pharmabot-mock-openai-1 wiremock/wiremock:3.5.4 UpView logs for a specific service:
docker compose logs -f web
docker compose logs -f mock-openaiRun a one-off command in a running container:
# Open a shell in the web container
docker compose exec web bash
# Run database migrations
docker compose exec web alembic upgrade head
# Check Redis connectivity
docker compose exec redis redis-cli -a $REDIS_PASSWORD pingThe docker compose up --watch Workflow
Compose Watch (introduced in Compose v2.22) is smarter than volume mounts alone. It understands different kinds of changes:
develop:
watch:
# Sync Python files into the container immediately
- action: sync
path: ./app
target: /app/app
# Rebuild the container if requirements change (can't hot-reload deps)
- action: rebuild
path: requirements.txt
# Restart the service if config files change
- action: sync+restart
path: ./config
target: /app/configActions:
sync: Copy changed files into the container filesystem. Uvicorn's--reloaddetects the change and reloads the app.rebuild: Rundocker compose buildand restart the service. Used for dependency changes.sync+restart: Sync files and restart the service (not rebuild the image). Faster than rebuild.
Trigger a rebuild manually:
# Rebuild only the web service without stopping others
docker compose up --build webStopping and Cleaning Up
# Stop containers (keep volumes)
docker compose down
# Stop containers AND remove volumes (data is lost)
docker compose down -v
# Remove stopped containers and dangling images
docker compose down --remove-orphans
docker image prune -fUse down -v only when you want a clean slate — it deletes Postgres data and Redis data. Day-to-day development uses down (without -v) to preserve your local database.
Troubleshooting Common Issues
Problem: web fails to start because postgres isn't ready
Check that postgres has a healthcheck and web has condition: service_healthy. Without this, Compose starts web before Postgres is accepting connections.
Problem: WireMock is running but the app still calls real Azure OpenAI
Check that AZURE_OPENAI_ENDPOINT in .env.local is set to http://mock-openai:8080, not the real Azure URL. Also check that mock-openai is on the same Docker network as web.
Problem: Port conflict on 5432 (local Postgres already running)
Either stop your local Postgres or change the host port mapping:
postgres:
ports:
- "5433:5432" # Map to host port 5433 insteadProblem: File changes don't trigger hot reload
On Windows with WSL2, file system events can be slow. Use docker compose up --watch instead of relying on uvicorn's file watcher — Compose Watch uses inotify more reliably.
Summary
Docker Compose transforms local AI development from "run four things manually and hope they connect" to a single-command, reproducible environment. Key practices:
.env.localfor secrets — never committed, always gitignored- Mock Azure OpenAI with WireMock — develop without real API calls
depends_onwithcondition: service_healthy— wait for databases to be truly ready, not just started--watchflag — smarter file sync than raw volume mountsprofiles:for optional services (pgAdmin, monitoring) — don't bloat the default stack
In the next lesson, we'll take this stack and deploy it through a GitHub Actions pipeline — from test, to build, to push to ACR, to deploy to Azure Container Apps.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.