Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 15, 202610 min read
LLMOpsDockerDocker ComposeFastAPIAILocal Development
Share:𝕏

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:

Bash
docker compose up

And 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 vars

The .env.local File

Never commit real secrets. Instead, commit a .env.example that documents every required variable:

Bash
# .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=true

Developers copy this to .env.local and fill in real values (or use the mock values for local dev):

Bash
# .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=true

For 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:

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

YAML
# 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: bridge

depends_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)

YAML
depends_on:
  - postgres

This 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)

YAML
depends_on:
  postgres:
    condition: service_healthy

This 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:

YAML
postgres:
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U pharmabot -d pharmabot"]
    interval: 5s
    timeout: 5s
    retries: 10
    start_period: 15s

The 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

YAML
volumes:
  - ./app:/app/app:delegated

This 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:

DOCKERFILE
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:

Bash
docker compose up

Start with Compose Watch (auto-sync file changes without restart):

Bash
docker compose up --watch

Start in detached mode (background):

Bash
docker compose up -d

Start with the tools profile (includes pgAdmin):

Bash
docker compose --profile tools up

Check what's running:

Bash
docker compose ps

Expected 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     Up

View logs for a specific service:

Bash
docker compose logs -f web
docker compose logs -f mock-openai

Run a one-off command in a running container:

Bash
# 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 ping

The docker compose up --watch Workflow

Compose Watch (introduced in Compose v2.22) is smarter than volume mounts alone. It understands different kinds of changes:

YAML
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/config

Actions:

  • sync: Copy changed files into the container filesystem. Uvicorn's --reload detects the change and reloads the app.
  • rebuild: Run docker compose build and 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:

Bash
# Rebuild only the web service without stopping others
docker compose up --build web

Stopping and Cleaning Up

Bash
# 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 -f

Use 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:

YAML
postgres:
  ports:
    - "5433:5432"   # Map to host port 5433 instead

Problem: 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.local for secrets — never committed, always gitignored
  • Mock Azure OpenAI with WireMock — develop without real API calls
  • depends_on with condition: service_healthy — wait for databases to be truly ready, not just started
  • --watch flag — smarter file sync than raw volume mounts
  • profiles: 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.