############################################################################## # GCTRL Production Docker Compose # # Usage: # cp .env.production.example .env.production # # Edit .env.production with real secrets # docker compose -f docker-compose.prod.yml --env-file .env.production up -d # # Features: # - Health checks on all services # - Resource limits (memory, CPU) # - Restart policies # - Named volumes for persistence # - Caddy reverse proxy with automatic SSL # - No exposed internal ports (only Caddy 80/443) # - Secrets via environment variables ############################################################################## services: # ─── Reverse Proxy (SSL termination) ──────────────────────────────── caddy: image: caddy:2-alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./infrastructure/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config depends_on: api: condition: service_healthy web: condition: service_started networks: - GCTRL deploy: resources: limits: memory: 128M # ─── API Server ───────────────────────────────────────────────────── api: build: ./services/api container_name: gctrl-api restart: unless-stopped environment: - NODE_ENV=production - PHOENIX_OTLP_URL=${PHOENIX_OTLP_URL:-} - PHOENIX_SERVICE_NAME=gctrl-api - PORT=4000 - DATABASE_URL=postgresql://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB} - REDIS_URL=redis://redis:6379 - NEO4J_URI=bolt://neo4j:7687 - NEO4J_USER=${NEO4J_USER} - NEO4J_PASSWORD=${NEO4J_PASSWORD} - JWT_SECRET=${JWT_SECRET} - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET} - MAIL_HOST=${MAIL_HOST:-smtp.mailgun.org} - MAIL_PORT=${MAIL_PORT:-587} - MAIL_USER=${MAIL_USER:-} - MAIL_PASS=${MAIL_PASS:-} - FRONTEND_URL=${FRONTEND_URL:-https://app.GCTRL.ai} - GCTRL_BASE_URL=${GCTRL_BASE_URL:-https://api.GCTRL.ai} - KEX_WORKER_URL=http://kex:4010 - QDRANT_URL=http://qdrant:6333 - QDRANT_API_KEY=${QDRANT_API_KEY:-} - OLLAMA_BASE=http://ollama:11434 - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - MICROSOFT_CLIENT_ID=${MICROSOFT_CLIENT_ID:-} - MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET:-} - SLACK_CLIENT_ID=${SLACK_CLIENT_ID:-} - SLACK_CLIENT_SECRET=${SLACK_CLIENT_SECRET:-} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:4000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] interval: 30s timeout: 10s retries: 3 start_period: 30s depends_on: postgres: condition: service_healthy redis: condition: service_healthy neo4j: condition: service_healthy networks: - GCTRL - ollama-net deploy: resources: limits: memory: 1G cpus: "2.0" # ─── Frontend ─────────────────────────────────────────────────────── web: build: context: ./services/web args: VITE_API_URL: ${VITE_API_URL:-/api} container_name: gctrl-web restart: unless-stopped depends_on: api: condition: service_healthy networks: - GCTRL deploy: resources: limits: memory: 128M # ─── KEX Worker ───────────────────────────────────────────────────── kex: build: ./services/kex container_name: gctrl-kex restart: unless-stopped environment: - OLLAMA_BASE=http://ollama:11434 - NEO4J_URI=bolt://neo4j:7687 - NEO4J_USER=${NEO4J_USER} - NEO4J_PASSWORD=${NEO4J_PASSWORD} - REDIS_URL=redis://redis:6379 - PG_URL=postgresql://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB} - QDRANT_URL=http://qdrant:6333 - QDRANT_API_KEY=${QDRANT_API_KEY:-} - KEX_WORKER_THREADS=${KEX_WORKER_THREADS:-1} - WHISPER_MODEL=${WHISPER_MODEL:-base} - PHOENIX_OTLP_URL=${PHOENIX_OTLP_URL:-} - PHOENIX_SERVICE_NAME=gctrl-kex - GLINER_MODEL=${GLINER_MODEL:-} - GLINER_FALLBACK_MODEL=${GLINER_FALLBACK_MODEL:-} - NER_THRESHOLD_BI=${NER_THRESHOLD_BI:-} healthcheck: test: ["CMD", "python3", "-c", "import requests; r=requests.get('http://localhost:4010/health'); exit(0 if r.ok else 1)"] interval: 60s timeout: 15s retries: 3 start_period: 120s volumes: - kex-models:/app/models depends_on: redis: condition: service_healthy neo4j: condition: service_healthy networks: - GCTRL - ollama-net deploy: resources: limits: memory: 8G reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] # ─── FUSE Worker ──────────────────────────────────────────────────── fuse: build: ./services/fuse container_name: gctrl-fuse restart: unless-stopped environment: - REDIS_URL=redis://redis:6379 - NEO4J_URI=bolt://neo4j:7687 - NEO4J_USER=${NEO4J_USER} - NEO4J_PASSWORD=${NEO4J_PASSWORD} - PG_URL=postgresql://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB} - QDRANT_URL=http://qdrant:6333 - QDRANT_API_KEY=${QDRANT_API_KEY:-} - PHOENIX_OTLP_URL=${PHOENIX_OTLP_URL:-} - PHOENIX_SERVICE_NAME=gctrl-fuse depends_on: redis: condition: service_healthy neo4j: condition: service_healthy networks: - GCTRL deploy: resources: limits: memory: 2G # ─── Semantic Resolver ────────────────────────────────────────────── resolver: image: ghcr.io/gctrl-tech/fusion-engine:latest container_name: gctrl-resolver restart: unless-stopped environment: - JDK_JAVA_OPTIONS=-Xmx2G command: ["-s"] networks: - GCTRL deploy: resources: limits: memory: 3G # ─── PostgreSQL ───────────────────────────────────────────────────── postgres: image: postgres:16-alpine container_name: gctrl-postgres restart: unless-stopped environment: - POSTGRES_USER=${PG_USER} - POSTGRES_PASSWORD=${PG_PASSWORD} - POSTGRES_DB=${PG_DB} healthcheck: test: ["CMD-SHELL", "pg_isready -U ${PG_USER:-GCTRL}"] interval: 10s timeout: 5s retries: 5 volumes: - GCTRL-pgdata:/var/lib/postgresql/data - ./shared/db/migrations:/docker-entrypoint-initdb.d networks: - GCTRL deploy: resources: limits: memory: 2G # ─── Redis ────────────────────────────────────────────────────────── redis: image: redis:7-alpine container_name: gctrl-redis restart: unless-stopped command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 volumes: - GCTRL-redis:/data networks: - GCTRL deploy: resources: limits: memory: 768M # ─── Neo4j ────────────────────────────────────────────────────────── neo4j: image: neo4j:2025.02.3-community container_name: gctrl-neo4j restart: unless-stopped environment: - NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD} - NEO4J_PLUGINS=["apoc"] - NEO4J_server_memory_heap_initial__size=512m - NEO4J_server_memory_heap_max__size=2G - NEO4J_server_memory_pagecache_size=1G healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"] interval: 15s timeout: 10s retries: 5 start_period: 30s volumes: - GCTRL-neo4j:/data networks: - GCTRL deploy: resources: limits: memory: 4G # ─── Qdrant Vector Store ──────────────────────────────────────────── qdrant: image: qdrant/qdrant:latest container_name: gctrl-qdrant restart: unless-stopped # Optional API key. Qdrant treats an EMPTY QDRANT__SERVICE__API_KEY as the key "" # and then answers 401 to every client without a header, so the variable is only # exported when QDRANT_API_KEY is set; unset/empty = no auth, exactly as before. # (Keep in lockstep with QDRANT_API_KEY on kex, fuse and api.) environment: - QDRANT_API_KEY=${QDRANT_API_KEY:-} command: ["bash", "-c", "if [ -n \"$${QDRANT_API_KEY}\" ]; then export QDRANT__SERVICE__API_KEY=\"$${QDRANT_API_KEY}\"; fi; unset QDRANT_API_KEY; exec ./entrypoint.sh"] volumes: - GCTRL-qdrant:/qdrant/storage networks: - GCTRL deploy: resources: limits: memory: 2G # ─── Prometheus (Monitoring) ──────────────────────────────────────── prometheus: image: prom/prometheus:latest container_name: gctrl-prometheus restart: unless-stopped volumes: - ./infrastructure/prometheus.yml:/etc/prometheus/prometheus.yml:ro - GCTRL-prometheus:/prometheus networks: - GCTRL deploy: resources: limits: memory: 512M # ─── Grafana (Dashboards) ────────────────────────────────────────── grafana: image: grafana/grafana:latest container_name: gctrl-grafana restart: unless-stopped environment: - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin} - GF_SERVER_ROOT_URL=${GRAFANA_URL:-http://localhost:3100} volumes: - GCTRL-grafana:/var/lib/grafana networks: - GCTRL deploy: resources: limits: memory: 256M # ─── Umami (cookieless web analytics) ─────────────────────────────── # Self-hosted, GDPR-by-design: no cookies, no consent banner, data stays # on our infra. Served at https://analytics.gctrl.tech (Caddy → :3000). # Its own Postgres keeps analytics physically isolated from the KG platform. umami: image: ghcr.io/umami-software/umami:postgresql-latest container_name: gctrl-umami restart: unless-stopped environment: - DATABASE_TYPE=postgresql - DATABASE_URL=postgresql://umami:${UMAMI_DB_PASSWORD}@umami-db:5432/umami - APP_SECRET=${UMAMI_APP_SECRET} depends_on: umami-db: condition: service_healthy networks: - GCTRL deploy: resources: limits: memory: 512M umami-db: image: postgres:16-alpine container_name: gctrl-umami-db restart: unless-stopped environment: - POSTGRES_USER=umami - POSTGRES_PASSWORD=${UMAMI_DB_PASSWORD} - POSTGRES_DB=umami healthcheck: test: ["CMD-SHELL", "pg_isready -U umami"] interval: 10s timeout: 5s retries: 5 volumes: - umami-db-data:/var/lib/postgresql/data networks: - GCTRL deploy: resources: limits: memory: 512M # ─── Volumes ──────────────────────────────────────────────────────── volumes: caddy-data: caddy-config: GCTRL-pgdata: GCTRL-redis: GCTRL-neo4j: GCTRL-qdrant: GCTRL-prometheus: GCTRL-grafana: kex-models: umami-db-data: # ─── Networks ─────────────────────────────────────────────────────── networks: GCTRL: driver: bridge ollama-net: external: true name: self-hosted-ai-starter-kit_demo