Docker Deployment¶
This chapter walks through containerizing your Gaia application with Docker — a multi-stage build for the FastAPI backend, an optimized build for the React frontend served by nginx, and a docker-compose.yml that ties everything together.
Architecture Overview¶
┌─────────────────────────────────────────────────────┐
│ docker compose │
│ │
│ ┌──────────────┐ ┌───────────────────────┐ │
│ │ frontend │ :80 │ backend │ │
│ │ (nginx) │────────▶│ (FastAPI/Uvicorn) │ │
│ │ │ │ │ │
│ └──────────────┘ └───────────┬───────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ sessions.db │ │
│ │ (volume mount) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────┘
│
┌────────▼────────┐
│ Gaia API │
│ (Cohesity Cloud) │
└─────────────────┘
Backend Dockerfile¶
The backend uses a multi-stage build to keep the final image minimal. The first stage installs dependencies; the second copies only what's needed to run.
# backend/Dockerfile
# ── Stage 1: Install dependencies ────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Stage 2: Runtime image ───────────────────────────────────────────
FROM python:3.12-slim
# Run as non-root for security
RUN groupadd -r gaia && useradd -r -g gaia -d /app -s /sbin/nologin gaia
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application source
COPY ./app ./app
# Create data directory for SQLite
RUN mkdir -p /app/data && chown -R gaia:gaia /app
USER gaia
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Why multi-stage?
The builder stage pulls in compilers and header files needed by some Python packages. The final image only contains the compiled results, cutting image size by 50% or more.
Backend requirements.txt¶
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
httpx>=0.27.0
pydantic>=2.6.0
pydantic-settings>=2.1.0
python-dotenv>=1.0.0
Frontend Dockerfile¶
The frontend build compiles your React/Vite app into static assets, then serves them with nginx.
# frontend/Dockerfile
# ── Stage 1: Build the React app ─────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build
# ── Stage 2: Serve with nginx ────────────────────────────────────────
FROM nginx:1.27-alpine
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Custom nginx config
COPY nginx.conf /etc/nginx/conf.d/app.conf
# Copy built assets
COPY --from=builder /build/dist /usr/share/nginx/html
# Run as non-root
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
USER nginx
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
CMD ["nginx", "-g", "daemon off;"]
nginx Configuration¶
The nginx config proxies /api requests to the backend and serves the React SPA for all other routes:
# frontend/nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy API requests to the backend
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE streaming support
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
# SPA fallback — serve index.html for all non-file routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
SSE proxy configuration
The proxy_buffering off and proxy_cache off directives are essential for streaming responses. Without them, nginx buffers SSE events and delivers them in batches instead of token-by-token.
Docker Compose¶
The docker-compose.yml brings both services together with proper networking, environment injection, health checks, and volume mounts.
# docker-compose.yml
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: gaia-backend
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- ./backend/.env
environment:
- ALLOW_CORS_ORIGIN=http://localhost
volumes:
- backend-data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: /api
container_name: gaia-frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
backend-data:
driver: local
Key Design Decisions¶
| Decision | Rationale |
|---|---|
env_file for backend secrets | Keeps GAIA_API_KEY out of the compose file and out of version control. |
VITE_API_BASE_URL=/api as build arg | The frontend is compiled to route all API calls through nginx's /api proxy. |
depends_on with health check | The frontend container waits until the backend is healthy before starting. |
Named volume backend-data | SQLite database persists across container restarts and rebuilds. |
restart: unless-stopped | Containers automatically recover from crashes but respect manual stops. |
Building and Running¶
Start Everything¶
This builds both images (if needed) and starts the services. The frontend will be available at http://localhost and the backend at http://localhost:8000.
Run in the Background¶
View Logs¶
Stop and Remove¶
# Stop containers (preserves volumes)
docker compose down
# Stop and remove volumes (deletes SQLite data)
docker compose down -v
Rebuild a Single Service¶
Environment Variable Injection¶
There are three ways to pass environment variables to containers:
The .env file in the backend directory is loaded directly. This is the cleanest approach for local development and CI.
Variables are read from the host shell. Useful in CI where secrets are injected as environment variables.
Precedence order
When the same variable is defined in multiple places, Docker Compose uses this precedence (highest first): shell environment > .env file next to compose > env_file directive > Dockerfile ENV.
Volume Mounts¶
SQLite Database Persistence¶
The named volume backend-data maps to /app/data inside the container, where the SQLite database is stored:
To back up the database:
Development: Live Code Reload¶
For local development, mount your source code into the container and enable Uvicorn's reload mode:
# docker-compose.override.yml (development only)
services:
backend:
volumes:
- ./backend/app:/app/app:ro
- backend-data:/app/data
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
Health Checks¶
Both containers include health checks so Docker (and orchestrators like Kubernetes) can monitor service health.
Backend Health Endpoint¶
Add a lightweight health endpoint to your FastAPI app:
# backend/app/main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health():
return {"status": "healthy"}
Verifying Health¶
# Check container health status
docker compose ps
# Example output:
# NAME SERVICE STATUS PORTS
# gaia-backend backend Up 2 minutes (healthy) 0.0.0.0:8000->8000/tcp
# gaia-frontend frontend Up 2 minutes (healthy) 0.0.0.0:80->80/tcp
Production Docker Tips¶
Use Non-Root Users¶
Both Dockerfiles already create and switch to non-root users (gaia for backend, nginx for frontend). This limits the blast radius if a container is compromised.
Minimize Image Size¶
| Technique | Impact |
|---|---|
| Multi-stage builds | Only runtime dependencies in final image |
python:3.12-slim base | ~150 MB vs. ~900 MB for full image |
node:20-alpine for build | Smaller builder; discarded in final stage |
nginx:1.27-alpine for serve | ~40 MB final frontend image |
--no-cache-dir with pip | Avoids caching wheels in the image |
Layer Caching¶
Order your COPY instructions from least-changed to most-changed so Docker can cache layers effectively:
# Good: dependencies change less often than source code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app ./app
# Bad: any source change invalidates the pip install cache
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
Pin Base Image Versions¶
Always use specific version tags rather than latest:
# Good
FROM python:3.12-slim
FROM nginx:1.27-alpine
# Bad
FROM python:latest
FROM nginx:alpine
Add .dockerignore¶
Keep build contexts small by excluding unnecessary files:
Next Steps¶
- Production Checklist — Ensure your deployment is secure, reliable, and observable.
- Environment Configuration — Revisit configuration for production overrides.