Skip to content

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

Text Only
┌─────────────────────────────────────────────────────┐
│                  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.

Docker
# 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

Text Only
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.

Docker
# 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:

Nginx Configuration File
# 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.

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

Bash
docker compose up --build

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

Bash
docker compose up --build -d

View Logs

Bash
# All services
docker compose logs -f

# Backend only
docker compose logs -f backend

Stop and Remove

Bash
# Stop containers (preserves volumes)
docker compose down

# Stop and remove volumes (deletes SQLite data)
docker compose down -v

Rebuild a Single Service

Bash
docker compose build backend
docker compose up -d backend

Environment Variable Injection

There are three ways to pass environment variables to containers:

YAML
services:
  backend:
    env_file:
      - ./backend/.env

The .env file in the backend directory is loaded directly. This is the cleanest approach for local development and CI.

YAML
services:
  backend:
    environment:
      - GAIA_API_KEY=${GAIA_API_KEY}
      - GAIA_BASE_URL=${GAIA_BASE_URL}

Variables are read from the host shell. Useful in CI where secrets are injected as environment variables.

Bash
# .env (next to docker-compose.yml)
GAIA_API_KEY=my-api-key
GAIA_BASE_URL=https://helios.cohesity.com/v2/mcm/gaia

Docker Compose automatically reads a .env file in the project root for variable substitution in the compose file.

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:

YAML
volumes:
  backend-data:
    driver: local

To back up the database:

Bash
docker compose cp backend:/app/data/sessions.db ./backup-sessions.db

Development: Live Code Reload

For local development, mount your source code into the container and enable Uvicorn's reload mode:

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

Python
# backend/app/main.py
from fastapi import FastAPI

app = FastAPI()


@app.get("/health")
async def health():
    return {"status": "healthy"}

Verifying Health

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

Docker
# Good: dependencies change less often than source code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app ./app
Docker
# 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:

Docker
# 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:

Text Only
# .dockerignore
.git
.env
.env.*
__pycache__
*.pyc
node_modules
.venv
*.md
.cursor/

Next Steps