Module 8: Capstone Project — Deployed AI System
2. Architecture Integration — Connecting the Pieces
Description
In this capsule you'll understand how Docker images, CI/CD pipelines, and deployment platforms connect into a coherent production flow. You won't learn new tools — you'll see how the tools you already know fit together. The difference between "I know how to use Docker" and "I know how to put an AI system in production" is exactly this: integration.
Context: In previous modules, you worked on each piece separately: optimized Docker images (M2), CI/CD with GitHub Actions (#16), deployment platforms (M7). Now you connect them. The diagram that comes out of this capsule is the map of everything you'll run in the following capsules.
The Integration Architecture
The complete flow
When you git push to your main branch, this is what should happen — without manual intervention:
Developer GitHub Platform
│ │ │
│ git push main │ │
├────────────────────────►│ │
│ │ Trigger workflow │
│ ├──────┐ │
│ │ │ Run tests │
│ │ │ Build image │
│ │ │ Push to registry │
│ │◄─────┘ │
│ │ │
│ │ Deploy (push/webhook) │
│ ├─────────────────────────►│
│ │ │ Pull image
│ │ │ Start container
│ │ │ Health check
│ │ │
│ │ Post-deploy validation │
│ ├──────┐ │
│ │ │ Smoke tests │
│ │ │ Verify inference │
│ │◄─────┘ │
│ │ │
│ ✅ Deploy complete │ │
│◄────────────────────────┤ │
Each arrow is an integration point. Each integration point is a place where something can fail. This capsule prepares you for each one.
The three components
┌─────────────────────────────────────────────────────────┐
│ INTEGRATION ARCHITECTURE │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Docker │───►│ CI/CD │───►│ Platform │ │
│ │ Image │ │ Pipeline │ │ (Deploy) │ │
│ └──────────┘ └──────────────┘ └───────────────┘ │
│ │ │ │ │
│ Dockerfile GitHub Actions Render/Railway │
│ .dockerignore build + test Fly.io/AWS │
│ Multi-stage Push to registry Pull + run │
│ Deploy trigger Health check │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ CONFIGURATION LAYER │ │
│ │ .env.local │ .env.staging │ .env.production │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Component 1: Optimized Docker Image
What you already have from Module 2
Your Dockerfile must produce an image that:
- Is reproducible (same version, same result)
- Is small (multi-stage build)
- Contains no secrets (no
.envin the image) - Has an integrated health check
Docker Image for production
# === Stage 1: Builder ===
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
COPY src/ ./src/
# === Stage 2: Runtime ===
FROM python:3.11-slim AS runtime
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app/src ./src/
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
The .dockerignore that protects your image
.git
.github
.env
.env.*
__pycache__
*.pyc
.pytest_cache
docs/
tests/
*.md
.vscode
.cursor
node_modules
Local verification before integrating
# Build the image
docker build -t my-ai-app:latest .
# Run locally
docker run -d --name test-app \
-p 8000:8000 \
-e OPENAI_API_KEY=$OPENAI_API_KEY \
my-ai-app:latest
# Verify health
curl http://localhost:8000/health
# Verify inference
curl -X POST http://localhost:8000/api/inference \
-H "Content-Type: application/json" \
-d '{"prompt": "Explain what Docker is in one sentence"}'
# Cleanup
docker stop test-app && docker rm test-app
If your image doesn't work locally, it won't work in production. Verify BEFORE integrating.
Component 2: CI/CD Pipeline
How CI/CD connects with Docker
The GitHub Actions pipeline is the bridge between your code and the platform. Its job:
- Trigger: Activates when you push to main (or the branch you configure)
- Test: Runs unit and integration tests
- Build: Builds the Docker image
- Push: Uploads the image to a registry (if the platform requires it)
- Deploy: Triggers the deployment on the platform
- Validate: Runs smoke tests against production
Base pipeline for any platform
# .github/workflows/deploy.yml
name: Deploy AI System
on:
push:
branches: [main]
env:
PYTHON_VERSION: "3.11"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest httpx
- name: Run tests
run: pytest tests/ -v --tb=short
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ENVIRONMENT: test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# === YOUR PLATFORM-SPECIFIC SECTION GOES HERE ===
# See the per-platform variants below
validate:
needs: deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Wait for deployment
run: sleep 30
- name: Health check
run: |
response=$(curl -s -o /dev/null -w "%{http_code}" ${{ vars.PRODUCTION_URL }}/health)
if [ "$response" != "200" ]; then
echo "Health check failed with status $response"
exit 1
fi
- name: Smoke test - inference
run: |
response=$(curl -s -X POST ${{ vars.PRODUCTION_URL }}/api/inference \
-H "Content-Type: application/json" \
-d '{"prompt": "Test prompt: respond with OK"}')
echo "Response: $response"
if echo "$response" | grep -q "error"; then
echo "Smoke test failed"
exit 1
fi
Per-platform variants
Render — Deploy via automatic Git push:
# Render deploys automatically when it detects a push on the connected repo.
# You don't need a deploy step in GitHub Actions.
# Just configure the repo in the Render dashboard.
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Trigger Render deploy
run: |
curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK_URL }}"
# You get the deploy hook in Settings → Deploy Hook on Render
Railway — Deploy with the CLI:
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Railway CLI
run: npm install -g @railway/cli
- name: Deploy to Railway
run: railway up --service ${{ vars.RAILWAY_SERVICE_ID }}
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
Fly.io — Deploy with flyctl:
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Fly.io CLI
uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy to Fly.io
run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
AWS Lambda — Deploy with SAM or the Serverless Framework:
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Deploy with SAM
run: |
sam build
sam deploy --no-confirm-changeset --no-fail-on-empty-changeset
Component 3: Deployment Platform
What the platform does for you
Regardless of which one you choose, the platform takes care of:
What YOU provide: What the PLATFORM does:
───────────────────── ──────────────────────────
Docker image (or code) → Build the container
Environment variables → Secret injection
Health check endpoint → Health verification
App port → Routing + SSL/TLS
→ Public domain (*.railway.app, etc.)
→ Accessible logs
→ Automatic restart if it crashes
Minimum configuration per platform
For Render:
# render.yaml (Infrastructure as Code)
services:
- type: web
name: my-ai-app
env: docker
plan: free
healthCheckPath: /health
envVars:
- key: OPENAI_API_KEY
sync: false
- key: ENVIRONMENT
value: production
For Railway:
# railway.toml
[build]
builder = "dockerfile"
dockerfilePath = "Dockerfile"
[deploy]
healthcheckPath = "/health"
healthcheckTimeout = 30
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 5
For Fly.io:
# fly.toml
app = "my-ai-app"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 8000
force_https = true
[[http_service.checks]]
interval = "30s"
timeout = "10s"
grace_period = "5s"
method = "GET"
path = "/health"
Configuration Layer: Multi-environment
The same code, different configuration
Your app must work in three environments with the same Docker image, differentiated only by environment variables:
# src/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
environment: str = "development"
app_name: str = "AI System"
debug: bool = False
openai_api_key: str = ""
openai_model: str = "gpt-4o-mini"
log_level: str = "INFO"
cors_origins: list[str] = ["http://localhost:3000"]
class Config:
env_file = ".env"
@property
def is_production(self) -> bool:
return self.environment == "production"
@lru_cache()
def get_settings() -> Settings:
return Settings()
Variables per environment
# .env.local (development)
ENVIRONMENT=development
DEBUG=true
LOG_LEVEL=DEBUG
OPENAI_API_KEY=sk-...
CORS_ORIGINS=["http://localhost:3000"]
# .env.staging (if applicable)
ENVIRONMENT=staging
DEBUG=false
LOG_LEVEL=INFO
OPENAI_API_KEY=sk-...
CORS_ORIGINS=["https://staging.your-domain.com"]
# Variables on the platform (production)
# NOT in a file — configured in the dashboard or CI/CD secrets
ENVIRONMENT=production
DEBUG=false
LOG_LEVEL=WARNING
OPENAI_API_KEY=sk-... # In the platform's secrets
CORS_ORIGINS=["https://your-domain.com"]
Health check endpoint
# src/main.py
from fastapi import FastAPI
from src.config import get_settings
app = FastAPI()
settings = get_settings()
@app.get("/health")
async def health_check():
return {
"status": "healthy",
"environment": settings.environment,
"version": "1.0.0",
}
@app.post("/api/inference")
async def inference(request: InferenceRequest):
# Your AI inference logic
...
Complete Integration Diagram
Everything together
┌──────────────────────────────────────────────────────────────┐
│ LOCAL DEVELOPMENT │
│ │
│ docker compose up → localhost:8000 → test manually │
│ .env.local loaded │
└──────────────────────┬───────────────────────────────────────┘
│
git push main
│
┌──────────────────────▼───────────────────────────────────────┐
│ GITHUB ACTIONS │
│ │
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Test │──►│ Build │──►│ Deploy │──►│ Validate │ │
│ │ pytest │ │ docker │ │ platform│ │ smoke │ │
│ └─────────┘ └─────────┘ └──────────┘ └──────────┘ │
│ │
│ Secrets: OPENAI_API_KEY, PLATFORM_TOKEN, PRODUCTION_URL │
└──────────────────────────────────────────────────────────────┘
│
deploy trigger
│
┌──────────────────────▼───────────────────────────────────────┐
│ PRODUCTION PLATFORM │
│ │
│ Pull image → Start container → Health check → Live ✅ │
│ .env from platform secrets │
│ URL: https://your-app.platform.app │
│ Auto-restart on failure │
│ SSL/TLS included │
└──────────────────────────────────────────────────────────────┘
Common Failure Points
Where the integration breaks
| Point | What fails | Symptom | Solution |
|---|---|---|---|
| Docker build | Incompatible dependencies | Build fails in CI but not locally | Use an exact pip freeze > requirements.txt |
| Secrets | Variable not configured on the platform | App crashes on startup | Verify ALL env vars before deploy |
| Health check | Endpoint doesn't respond in time | Platform kills the container | Adjust the timeout, check the cold start |
| Port | App listens on a different port | Connection refused | Verify the PORT env var or hardcoded value |
| Registry | Image not accessible | Deploy fails: image not found | Verify registry permissions |
| CORS | Frontend can't call the API | Error in the browser, API works in curl | Configure CORS_ORIGINS for production |
Troubleshooting
Problem 1: "Docker build works locally but fails in GitHub Actions"
Cause: Platform difference (M1/M2 Mac vs Linux in CI), system dependencies, or corrupt cache.
Solution:
# Force a no-cache build in CI
- name: Build Docker image
run: docker build --no-cache -t my-app:latest .
# If you use dependencies with C extensions (numpy, etc.)
# make sure to install build tools
RUN apt-get update && apt-get install -y build-essential
Problem 2: "The deploy completes but the app doesn't respond"
Cause: The app crashes on startup because an environment variable or a dependent service is missing.
Solution:
import sys
from src.config import get_settings
settings = get_settings()
if not settings.openai_api_key:
print("ERROR: OPENAI_API_KEY not set", file=sys.stderr)
sys.exit(1)
Problem 3: "Health check passes but inference doesn't work"
Cause: The health check only verifies that the API responds, not that the inference service is configured correctly.
Solution: Implement a health check that verifies dependencies (see capsule 04).
Problem 4: "Everything works the first time but the redeploy fails"
Cause: Stale state on the platform, container doesn't restart cleanly.
Solution:
# Force a clean redeploy (Render)
curl -X POST "$RENDER_DEPLOY_HOOK_URL"
# Railway: force a redeploy
railway up --service SERVICE_ID
# Fly.io: redeploy
flyctl deploy --remote-only --strategy immediate
Problem 5: "Environment variables don't load in production"
Cause: The env vars are in local .env but not configured on the platform.
Solution:
# List configured variables
# Railway
railway variables
# Fly.io
flyctl secrets list
# Render: check in Dashboard → Environment
# Verification script
python -c "
from src.config import get_settings
s = get_settings()
print(f'Environment: {s.environment}')
print(f'API Key set: {bool(s.openai_api_key)}')
print(f'Model: {s.openai_model}')
"
Hands-On Exercises
Exercise 1: Verify your Docker image
Build your Docker image and verify that it works before integrating it with CI/CD.
# Build
docker build -t my-ai-app:test .
# Run with env vars
docker run -d --name test-integration \
-p 8000:8000 \
-e OPENAI_API_KEY=$OPENAI_API_KEY \
-e ENVIRONMENT=test \
my-ai-app:test
# Verify
curl http://localhost:8000/health
curl -X POST http://localhost:8000/api/inference \
-H "Content-Type: application/json" \
-d '{"prompt": "Say hello"}'
See solution
# Successful build
$ docker build -t my-ai-app:test .
# [+] Building 45.3s (12/12) FINISHED
# Successful run
$ docker run -d --name test-integration \
-p 8000:8000 \
-e OPENAI_API_KEY=$OPENAI_API_KEY \
-e ENVIRONMENT=test \
my-ai-app:test
# abc123def456...
# Health check
$ curl http://localhost:8000/health
# {"status":"healthy","environment":"test","version":"1.0.0"}
# Inference
$ curl -s -X POST http://localhost:8000/api/inference \
-H "Content-Type: application/json" \
-d '{"prompt": "Say hello"}'
# {"response":"Hello! How can I help you today?","model":"gpt-4o-mini"}
# Cleanup
$ docker stop test-integration && docker rm test-integration
If the health check fails, review the logs with docker logs test-integration. If inference fails but the health check passes, your API key wasn't injected correctly.
Exercise 2: Create the base GitHub Actions pipeline
Create the .github/workflows/deploy.yml file with the test, deploy, and validate jobs.
See solution
# .github/workflows/deploy.yml
name: Deploy AI System
on:
push:
branches: [main]
workflow_dispatch:
env:
PYTHON_VERSION: "3.11"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- run: pip install -r requirements.txt && pip install pytest httpx
- run: pytest tests/ -v --tb=short
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ENVIRONMENT: test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Add YOUR platform's steps (Render/Railway/Fly.io/AWS)
- name: Deploy
run: echo "Add your platform-specific deploy step here"
validate:
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Wait for deployment
run: sleep 45
- name: Health check
run: |
for i in 1 2 3 4 5; do
status=$(curl -s -o /dev/null -w "%{http_code}" ${{ vars.PRODUCTION_URL }}/health)
if [ "$status" = "200" ]; then
echo "Health check passed"
exit 0
fi
echo "Attempt $i: status $status, retrying in 15s..."
sleep 15
done
echo "Health check failed after 5 attempts"
exit 1
The validate job has retry logic because the deploy can take longer than expected. 5 attempts × 15 seconds = 75 seconds of tolerance.
Exercise 3: Configure multi-environment
Create the src/config.py file with Pydantic Settings and verify that it works locally and in production.
See solution
# src/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
from typing import Optional
class Settings(BaseSettings):
environment: str = "development"
app_name: str = "AI System"
debug: bool = False
version: str = "1.0.0"
openai_api_key: str = ""
openai_model: str = "gpt-4o-mini"
openai_temperature: float = 0.7
log_level: str = "INFO"
cors_origins: list[str] = ["http://localhost:3000"]
port: int = 8000
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@property
def is_production(self) -> bool:
return self.environment == "production"
@property
def is_development(self) -> bool:
return self.environment == "development"
def validate_for_production(self) -> list[str]:
"""Returns a list of configuration errors."""
errors = []
if not self.openai_api_key:
errors.append("OPENAI_API_KEY is required")
if self.debug and self.is_production:
errors.append("DEBUG must be False in production")
return errors
@lru_cache()
def get_settings() -> Settings:
return Settings()
Verification:
# Local
ENVIRONMENT=development python -c "
from src.config import get_settings
s = get_settings()
print(f'Env: {s.environment}, Debug: {s.debug}, Key set: {bool(s.openai_api_key)}')
errors = s.validate_for_production()
print(f'Production-ready: {len(errors) == 0}, Errors: {errors}')
"
Exercise 4: Draw your integration diagram
Using this capsule's template, draw the integration diagram specific to your stack. Include: your chosen platform, the services your app needs, and the environment variables per environment.
See solution
Example for a RAG app with Railway:
┌───────────────────────────────────────────────┐
│ LOCAL (docker compose up) │
│ │
│ FastAPI (:8000) ──► ChromaDB (:8001) │
│ │ │
│ └──► OpenAI API (external) │
│ │
│ ENV: .env.local │
│ OPENAI_API_KEY=sk-dev... │
│ ENVIRONMENT=development │
└──────────────────┬────────────────────────────┘
│ git push main
┌──────────────────▼────────────────────────────┐
│ GITHUB ACTIONS │
│ │
│ pytest → build check → railway up │
│ │
│ Secrets: OPENAI_API_KEY, RAILWAY_TOKEN │
└──────────────────┬────────────────────────────┘
│ deploy
┌──────────────────▼────────────────────────────┐
│ RAILWAY (production) │
│ │
│ FastAPI service ──► ChromaDB volume │
│ │ │
│ └──► OpenAI API (external) │
│ │
│ URL: https://my-rag-app.railway.app │
│ ENV: configured in Railway dashboard │
│ OPENAI_API_KEY=sk-prod... │
│ ENVIRONMENT=production │
└───────────────────────────────────────────────┘
What matters is that you identify each service, each environment variable, and each connection point between components.
Summary
- The integration architecture connects three components: Docker image, CI/CD pipeline, and deployment platform
- The flow is:
git push→ test → build → deploy → validate — without manual intervention - Multi-environment means the same code with different configuration: local, staging, production
- Environment variables are the mechanism for differentiating environments — never hardcode secrets
- The failure points are at the interfaces: unconfigured secrets, incorrect ports, health checks with timeouts
- The per-platform configuration varies (render.yaml, railway.toml, fly.toml) but the concept is the same
- Verify locally before integrating — if Docker doesn't work on your machine, it won't work in production
Additional Resources
- GitHub Actions Documentation — Complete GitHub Actions reference
- Docker Multi-stage Builds — Docker image optimization
- Pydantic Settings Management — Configuration with Pydantic
- Render Deploy Hooks — Automate deploys on Render
- Railway CLI Reference — Railway CLI for CI/CD
- Fly.io Continuous Deployment — CI/CD with Fly.io