From 2c65dc266d6d85a2091c1ad760e56432c0d795df Mon Sep 17 00:00:00 2001 From: Bobby Hyam Date: Thu, 5 Feb 2026 19:44:51 +0000 Subject: [PATCH 1/9] Add production Docker Compose configurations - docker-compose.traefik.yml: Full production with Traefik reverse proxy, auto-SSL via Let's Encrypt, isolated networks - docker-compose.simple.yml: Simple HTTP deployment with direct port access - frontend/Dockerfile.prod: Multi-stage build with nginx serving static assets - frontend/nginx.conf: SPA routing, gzip compression, caching, security headers - .env.traefik.example and .env.simple.example: Example environment files Key production features: - PostgreSQL 17-alpine with 600 max connections - Redis 7-alpine with AOF persistence - Named volumes instead of mounted code directories - Resource limits and health checks with start_period - Isolated networks for security Co-Authored-By: Claude Opus 4.5 --- .env.simple.example | 23 ++++ .env.traefik.example | 21 ++++ docker-compose.simple.yml | 200 ++++++++++++++++++++++++++++++ docker-compose.traefik.yml | 247 +++++++++++++++++++++++++++++++++++++ frontend/Dockerfile.prod | 27 ++++ frontend/nginx.conf | 37 ++++++ 6 files changed, 555 insertions(+) create mode 100644 .env.simple.example create mode 100644 .env.traefik.example create mode 100644 docker-compose.simple.yml create mode 100644 docker-compose.traefik.yml create mode 100644 frontend/Dockerfile.prod create mode 100644 frontend/nginx.conf diff --git a/.env.simple.example b/.env.simple.example new file mode 100644 index 00000000..9ee9fbda --- /dev/null +++ b/.env.simple.example @@ -0,0 +1,23 @@ +# Security (required for production - generate with: openssl rand -hex 32) +SECRET_KEY=claudex_default_secret_key_for_development_only + +# Database +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=claudex + +# Ports (change if needed) +BACKEND_PORT=8080 +FRONTEND_PORT=3000 +POSTGRES_PORT=5432 +REDIS_PORT=6379 + +# Docker sandbox image +DOCKER_IMAGE=ghcr.io/mng-dev-ai/claudex-sandbox:latest + +# Celery workers +CELERY_CONCURRENCY=25 +CELERY_WORKER_REPLICAS=8 + +# Logging +LOG_LEVEL=INFO diff --git a/.env.traefik.example b/.env.traefik.example new file mode 100644 index 00000000..1c5746f9 --- /dev/null +++ b/.env.traefik.example @@ -0,0 +1,21 @@ +# Domain configuration (required) +DOMAIN=example.com +ACME_EMAIL=admin@example.com + +# Security (required - generate with: openssl rand -hex 32) +SECRET_KEY=your_secure_secret_key_at_least_32_characters + +# Database +POSTGRES_USER=postgres +POSTGRES_PASSWORD=your_secure_database_password +POSTGRES_DB=claudex + +# Docker sandbox image +DOCKER_IMAGE=ghcr.io/mng-dev-ai/claudex-sandbox:latest + +# Celery workers +CELERY_CONCURRENCY=25 +CELERY_WORKER_REPLICAS=8 + +# Logging +LOG_LEVEL=INFO diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml new file mode 100644 index 00000000..53024700 --- /dev/null +++ b/docker-compose.simple.yml @@ -0,0 +1,200 @@ +services: + postgres: + image: postgres:17-alpine + container_name: claudex-postgres + restart: always + command: -c max_connections=600 + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-claudex} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - claudex-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + memory: 2G + + redis: + image: redis:7-alpine + container_name: claudex-redis + restart: always + command: redis-server --appendonly yes + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + networks: + - claudex-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + memory: 512M + + sandbox-setup: + image: docker:27-cli + container_name: claudex-sandbox-setup + restart: "no" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: sh -c "docker pull ${DOCKER_IMAGE:-ghcr.io/mng-dev-ai/claudex-sandbox:latest}" + networks: + - claudex-network + + api: + build: + context: ./backend + dockerfile: Dockerfile + container_name: claudex-api + restart: unless-stopped + command: ["api"] + user: root + privileged: true + group_add: + - "0" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + sandbox-setup: + condition: service_completed_successfully + ports: + - "${BACKEND_PORT:-8080}:8080" + volumes: + - storage_data:/app/storage + - /var/run/docker.sock:/var/run/docker.sock + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-claudex} + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=${SECRET_KEY:-claudex_default_secret_key_for_development_only} + - PYTHONUNBUFFERED=1 + - LOG_LEVEL=${LOG_LEVEL:-INFO} + networks: + - claudex-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + memory: 4G + + celery-beat: + build: + context: ./backend + dockerfile: Dockerfile + container_name: claudex-celery-beat + restart: unless-stopped + command: ["celery-beat"] + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - storage_data:/app/storage + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-claudex} + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=${SECRET_KEY:-claudex_default_secret_key_for_development_only} + - PYTHONUNBUFFERED=1 + - LOG_LEVEL=${LOG_LEVEL:-INFO} + networks: + - claudex-network + deploy: + resources: + limits: + memory: 512M + + celery-worker: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + command: ["celery-worker"] + user: root + privileged: true + group_add: + - "0" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + sandbox-setup: + condition: service_completed_successfully + volumes: + - storage_data:/app/storage + - /var/run/docker.sock:/var/run/docker.sock + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-claudex} + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=${SECRET_KEY:-claudex_default_secret_key_for_development_only} + - PYTHONUNBUFFERED=1 + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - CELERY_CONCURRENCY=${CELERY_CONCURRENCY:-25} + networks: + - claudex-network + deploy: + replicas: ${CELERY_WORKER_REPLICAS:-8} + resources: + limits: + memory: 4G + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + args: + - VITE_API_URL=http://localhost:${BACKEND_PORT:-8080} + - VITE_WS_URL=ws://localhost:${BACKEND_PORT:-8080}/api/v1/ws + container_name: claudex-frontend + restart: unless-stopped + depends_on: + - api + ports: + - "${FRONTEND_PORT:-3000}:80" + networks: + - claudex-network + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + memory: 256M + +networks: + claudex-network: + driver: bridge + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + storage_data: + driver: local diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml new file mode 100644 index 00000000..153afd1d --- /dev/null +++ b/docker-compose.traefik.yml @@ -0,0 +1,247 @@ +services: + traefik: + image: traefik:v3.0 + container_name: claudex-traefik + restart: always + command: + - "--api.dashboard=false" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--entrypoints.web.http.redirections.entrypoint.to=websecure" + - "--entrypoints.web.http.redirections.entrypoint.scheme=https" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik_letsencrypt:/letsencrypt + networks: + - claudex-proxy + healthcheck: + test: ["CMD", "traefik", "healthcheck"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + postgres: + image: postgres:17-alpine + container_name: claudex-postgres + restart: always + command: -c max_connections=600 + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-claudex} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - claudex-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + memory: 2G + + redis: + image: redis:7-alpine + container_name: claudex-redis + restart: always + command: redis-server --appendonly yes + volumes: + - redis_data:/data + networks: + - claudex-internal + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + memory: 512M + + sandbox-setup: + image: docker:27-cli + container_name: claudex-sandbox-setup + restart: "no" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: sh -c "docker pull ${DOCKER_IMAGE:-ghcr.io/mng-dev-ai/claudex-sandbox:latest}" + networks: + - claudex-sandbox-net + + api: + build: + context: ./backend + dockerfile: Dockerfile + container_name: claudex-api + restart: unless-stopped + command: ["api"] + user: root + privileged: true + group_add: + - "0" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + sandbox-setup: + condition: service_completed_successfully + volumes: + - storage_data:/app/storage + - /var/run/docker.sock:/var/run/docker.sock + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-claudex} + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=${SECRET_KEY} + - PYTHONUNBUFFERED=1 + - LOG_LEVEL=${LOG_LEVEL:-INFO} + networks: + - claudex-proxy + - claudex-internal + - claudex-sandbox-net + labels: + - "traefik.enable=true" + - "traefik.http.routers.api.rule=Host(`api.${DOMAIN}`)" + - "traefik.http.routers.api.entrypoints=websecure" + - "traefik.http.routers.api.tls.certresolver=letsencrypt" + - "traefik.http.services.api.loadbalancer.server.port=8080" + - "traefik.http.middlewares.api-headers.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.routers.api.middlewares=api-headers" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + memory: 4G + + celery-beat: + build: + context: ./backend + dockerfile: Dockerfile + container_name: claudex-celery-beat + restart: unless-stopped + command: ["celery-beat"] + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - storage_data:/app/storage + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-claudex} + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=${SECRET_KEY} + - PYTHONUNBUFFERED=1 + - LOG_LEVEL=${LOG_LEVEL:-INFO} + networks: + - claudex-internal + deploy: + resources: + limits: + memory: 512M + + celery-worker: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + command: ["celery-worker"] + user: root + privileged: true + group_add: + - "0" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + sandbox-setup: + condition: service_completed_successfully + volumes: + - storage_data:/app/storage + - /var/run/docker.sock:/var/run/docker.sock + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-claudex} + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=${SECRET_KEY} + - PYTHONUNBUFFERED=1 + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - CELERY_CONCURRENCY=${CELERY_CONCURRENCY:-25} + networks: + - claudex-internal + - claudex-sandbox-net + deploy: + replicas: ${CELERY_WORKER_REPLICAS:-8} + resources: + limits: + memory: 4G + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + args: + - VITE_API_URL=https://api.${DOMAIN} + - VITE_WS_URL=wss://api.${DOMAIN}/api/v1/ws + container_name: claudex-frontend + restart: unless-stopped + depends_on: + - api + networks: + - claudex-proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.frontend.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.frontend.entrypoints=websecure" + - "traefik.http.routers.frontend.tls.certresolver=letsencrypt" + - "traefik.http.services.frontend.loadbalancer.server.port=80" + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + memory: 256M + +networks: + claudex-proxy: + driver: bridge + claudex-internal: + driver: bridge + internal: true + claudex-sandbox-net: + driver: bridge + +volumes: + traefik_letsencrypt: + driver: local + postgres_data: + driver: local + redis_data: + driver: local + storage_data: + driver: local diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 00000000..40d1ec3e --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,27 @@ +# Stage 1: Build +FROM node:20-alpine AS builder + +WORKDIR /app + +# Build-time arguments for Vite environment variables +ARG VITE_API_URL +ARG VITE_WS_URL + +COPY package*.json ./ +RUN npm ci --legacy-peer-deps + +COPY . . +RUN npm run build + +# Stage 2: Production +FROM nginx:alpine + +# Copy built assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..72e60ecb --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Static assets - long cache + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback - serve index.html for all non-file routes + location / { + try_files $uri $uri/ /index.html; + } + + # Health check endpoint + location /health { + access_log off; + return 200 'OK'; + add_header Content-Type text/plain; + } +} From 5a2933e1f9f193ddd2138e99bc499f6f70b92542 Mon Sep 17 00:00:00 2001 From: Bobby Hyam Date: Thu, 5 Feb 2026 20:52:02 +0000 Subject: [PATCH 2/9] Fix Traefik SSL, healthcheck, and routing issues - Enable ping and add --ping flag to Traefik healthcheck - Set providers.docker.network to avoid arbitrary network selection for multi-network containers - Fix frontend healthcheck to use 127.0.0.1 instead of localhost (IPv6 mismatch on Alpine) - Add explicit network name to claudex-proxy to avoid project-name prefix issues Co-Authored-By: Claude Opus 4.6 --- docker-compose.traefik.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 153afd1d..9ca80b4b 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -4,9 +4,11 @@ services: container_name: claudex-traefik restart: always command: + - "--ping=true" - "--api.dashboard=false" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=claudex-proxy" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--entrypoints.web.http.redirections.entrypoint.to=websecure" @@ -24,7 +26,7 @@ services: networks: - claudex-proxy healthcheck: - test: ["CMD", "traefik", "healthcheck"] + test: ["CMD", "traefik", "healthcheck", "--ping"] interval: 30s timeout: 10s retries: 3 @@ -217,7 +219,7 @@ services: - "traefik.http.routers.frontend.tls.certresolver=letsencrypt" - "traefik.http.services.frontend.loadbalancer.server.port=80" healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"] + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/health"] interval: 30s timeout: 10s retries: 3 @@ -229,6 +231,7 @@ services: networks: claudex-proxy: + name: claudex-proxy driver: bridge claudex-internal: driver: bridge From 0ced66cea3c66fa89314aef25c043c759cbf21b3 Mon Sep 17 00:00:00 2001 From: Bobby Hyam Date: Thu, 5 Feb 2026 21:17:48 +0000 Subject: [PATCH 3/9] Add CI build workflows and parameterize image owner in compose files - Add GitHub Actions workflows for building backend and frontend images - Replace hardcoded image references with ${IMAGE_OWNER:-mng-dev-ai} in both compose files so forks can use their own GHCR images - Update frontend Dockerfile.prod to use placeholder-based build args with runtime env var injection via docker-entrypoint.sh - Add IMAGE_OWNER to .env example files Co-Authored-By: Claude Opus 4.6 --- .env.simple.example | 3 ++ .env.traefik.example | 3 ++ .github/workflows/build-backend.yml | 59 ++++++++++++++++++++++++++ .github/workflows/build-frontend.yml | 62 ++++++++++++++++++++++++++++ docker-compose.simple.yml | 22 ++++------ docker-compose.traefik.yml | 22 ++++------ frontend/Dockerfile.prod | 14 +++++-- frontend/docker-entrypoint.sh | 12 ++++++ 8 files changed, 164 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/build-backend.yml create mode 100644 .github/workflows/build-frontend.yml create mode 100755 frontend/docker-entrypoint.sh diff --git a/.env.simple.example b/.env.simple.example index 9ee9fbda..7a8a51bc 100644 --- a/.env.simple.example +++ b/.env.simple.example @@ -12,6 +12,9 @@ FRONTEND_PORT=3000 POSTGRES_PORT=5432 REDIS_PORT=6379 +# Docker image owner (change to your GitHub username for fork builds) +IMAGE_OWNER=mng-dev-ai + # Docker sandbox image DOCKER_IMAGE=ghcr.io/mng-dev-ai/claudex-sandbox:latest diff --git a/.env.traefik.example b/.env.traefik.example index 1c5746f9..e06515a7 100644 --- a/.env.traefik.example +++ b/.env.traefik.example @@ -10,6 +10,9 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=your_secure_database_password POSTGRES_DB=claudex +# Docker image owner (change to your GitHub username for fork builds) +IMAGE_OWNER=mng-dev-ai + # Docker sandbox image DOCKER_IMAGE=ghcr.io/mng-dev-ai/claudex-sandbox:latest diff --git a/.github/workflows/build-backend.yml b/.github/workflows/build-backend.yml new file mode 100644 index 00000000..92fd1e19 --- /dev/null +++ b/.github/workflows/build-backend.yml @@ -0,0 +1,59 @@ +name: Build Backend Image + +on: + push: + branches: [main] + paths: + - 'backend/**' + - '.github/workflows/build-backend.yml' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/claudex-backend + +jobs: + build-and-push: + name: Build and Push Backend Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,prefix= + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: backend + file: backend/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/build-frontend.yml b/.github/workflows/build-frontend.yml new file mode 100644 index 00000000..2e7d4ef1 --- /dev/null +++ b/.github/workflows/build-frontend.yml @@ -0,0 +1,62 @@ +name: Build Frontend Image + +on: + push: + branches: [main] + paths: + - 'frontend/**' + - '.github/workflows/build-frontend.yml' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/claudex-frontend + +jobs: + build-and-push: + name: Build and Push Frontend Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,prefix= + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: frontend + file: frontend/Dockerfile.prod + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VITE_API_BASE_URL=__VITE_API_BASE_URL__ + VITE_WS_URL=__VITE_WS_URL__ + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 53024700..e87e8c19 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -58,9 +58,7 @@ services: - claudex-network api: - build: - context: ./backend - dockerfile: Dockerfile + image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-backend:latest container_name: claudex-api restart: unless-stopped command: ["api"] @@ -100,9 +98,7 @@ services: memory: 4G celery-beat: - build: - context: ./backend - dockerfile: Dockerfile + image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-backend:latest container_name: claudex-celery-beat restart: unless-stopped command: ["celery-beat"] @@ -127,9 +123,7 @@ services: memory: 512M celery-worker: - build: - context: ./backend - dockerfile: Dockerfile + image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-backend:latest restart: unless-stopped command: ["celery-worker"] user: root @@ -162,18 +156,16 @@ services: memory: 4G frontend: - build: - context: ./frontend - dockerfile: Dockerfile.prod - args: - - VITE_API_URL=http://localhost:${BACKEND_PORT:-8080} - - VITE_WS_URL=ws://localhost:${BACKEND_PORT:-8080}/api/v1/ws + image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-frontend:latest container_name: claudex-frontend restart: unless-stopped depends_on: - api ports: - "${FRONTEND_PORT:-3000}:80" + environment: + - VITE_API_BASE_URL=http://localhost:${BACKEND_PORT:-8080}/api/v1 + - VITE_WS_URL=ws://localhost:${BACKEND_PORT:-8080}/api/v1/ws networks: - claudex-network healthcheck: diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 9ca80b4b..3b2d859e 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -87,9 +87,7 @@ services: - claudex-sandbox-net api: - build: - context: ./backend - dockerfile: Dockerfile + image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-backend:latest container_name: claudex-api restart: unless-stopped command: ["api"] @@ -137,9 +135,7 @@ services: memory: 4G celery-beat: - build: - context: ./backend - dockerfile: Dockerfile + image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-backend:latest container_name: claudex-celery-beat restart: unless-stopped command: ["celery-beat"] @@ -164,9 +160,7 @@ services: memory: 512M celery-worker: - build: - context: ./backend - dockerfile: Dockerfile + image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-backend:latest restart: unless-stopped command: ["celery-worker"] user: root @@ -200,16 +194,14 @@ services: memory: 4G frontend: - build: - context: ./frontend - dockerfile: Dockerfile.prod - args: - - VITE_API_URL=https://api.${DOMAIN} - - VITE_WS_URL=wss://api.${DOMAIN}/api/v1/ws + image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-frontend:latest container_name: claudex-frontend restart: unless-stopped depends_on: - api + environment: + - VITE_API_BASE_URL=https://api.${DOMAIN}/api/v1 + - VITE_WS_URL=wss://api.${DOMAIN}/api/v1/ws networks: - claudex-proxy labels: diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 40d1ec3e..c54e8171 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -3,9 +3,12 @@ FROM node:20-alpine AS builder WORKDIR /app -# Build-time arguments for Vite environment variables -ARG VITE_API_URL -ARG VITE_WS_URL +# Build-time arguments for Vite environment variables (use placeholders for pre-built images) +ARG VITE_API_BASE_URL=__VITE_API_BASE_URL__ +ARG VITE_WS_URL=__VITE_WS_URL__ + +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} +ENV VITE_WS_URL=${VITE_WS_URL} COPY package*.json ./ RUN npm ci --legacy-peer-deps @@ -22,6 +25,11 @@ COPY --from=builder /app/dist /usr/share/nginx/html # Copy nginx configuration COPY nginx.conf /etc/nginx/conf.d/default.conf +# Copy entrypoint script for runtime env var injection +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + EXPOSE 80 +ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh new file mode 100755 index 00000000..ad114699 --- /dev/null +++ b/frontend/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Replace build-time placeholders with runtime env vars +if [ -n "$VITE_API_BASE_URL" ]; then + find /usr/share/nginx/html -name '*.js' -exec sed -i "s|__VITE_API_BASE_URL__|${VITE_API_BASE_URL}|g" {} + +fi +if [ -n "$VITE_WS_URL" ]; then + find /usr/share/nginx/html -name '*.js' -exec sed -i "s|__VITE_WS_URL__|${VITE_WS_URL}|g" {} + +fi + +exec "$@" From 5c094db32268458861e2eea285f4e92ea4199dcf Mon Sep 17 00:00:00 2001 From: Bobby Hyam Date: Thu, 5 Feb 2026 21:45:48 +0000 Subject: [PATCH 4/9] Add Docker Compose production deployment docs to README Co-Authored-By: Claude Opus 4.6 --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index a0fce43c..9d73c6bd 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,39 @@ docker compose logs -f # Logs For production deployment on a VPS, see the [Coolify Installation Guide](docs/coolify-installation-guide.md). +### Docker Compose + +Two production-ready Docker Compose configurations are provided, both using pre-built images from GHCR. + +**Simple (direct port exposure):** + +```bash +cp .env.simple.example .env +# Edit .env — set SECRET_KEY at minimum +docker compose -f docker-compose.simple.yml up -d +``` + +Frontend at `http://localhost:3000`, API at `http://localhost:8080`. + +**Traefik (HTTPS with Let's Encrypt):** + +```bash +cp .env.traefik.example .env +# Edit .env — set DOMAIN, ACME_EMAIL, POSTGRES_PASSWORD, SECRET_KEY +docker compose -f docker-compose.traefik.yml up -d +``` + +Frontend at `https://DOMAIN`, API at `https://api.DOMAIN`. HTTP redirects to HTTPS automatically. + +Requires wildcard DNS pointing to your server. For example, if `DOMAIN=claudex.example.com`: + +``` +claudex.example.com A → your-server-ip +*.claudex.example.com A → your-server-ip +``` + +**Fork builds:** Set `IMAGE_OWNER` in your `.env` to your GitHub username to use images built from your fork. + ## API & Admin - **API Docs:** http://localhost:8080/api/v1/docs From d421c0b14511e20e4c0d27708b922e43cf1d5ef3 Mon Sep 17 00:00:00 2001 From: Bobby Hyam Date: Sun, 8 Feb 2026 13:03:12 +0000 Subject: [PATCH 5/9] Fix Traefik Docker API compat and CORS in traefik compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade Traefik v3.0 → v3.4 to fix incompatibility with Docker Engine 29.x (requires API v1.44+). Add ALLOWED_ORIGINS for the cross-origin traefik setup where frontend and API are on different subdomains. Co-Authored-By: Claude Opus 4.6 --- docker-compose.traefik.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 3b2d859e..8497c168 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -1,6 +1,6 @@ services: traefik: - image: traefik:v3.0 + image: traefik:v3.4 container_name: claudex-traefik restart: always command: @@ -111,6 +111,7 @@ services: - SECRET_KEY=${SECRET_KEY} - PYTHONUNBUFFERED=1 - LOG_LEVEL=${LOG_LEVEL:-INFO} + - ALLOWED_ORIGINS=https://${DOMAIN} networks: - claudex-proxy - claudex-internal From 3aafe46c6f08da5ce6691c476c7e789541dfe3d9 Mon Sep 17 00:00:00 2001 From: Bobby Hyam Date: Sun, 8 Feb 2026 13:17:44 +0000 Subject: [PATCH 6/9] =?UTF-8?q?Bump=20Traefik=20v3.4=20=E2=86=92=20v3.6=20?= =?UTF-8?q?for=20Docker=20Engine=2029.x=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.4 still uses Docker API v1.24; the auto-negotiation fix landed in v3.6.1. Verified on cldx-test.bobbyhyam.com with Docker Engine 29.2.1. Co-Authored-By: Claude Opus 4.6 --- docker-compose.traefik.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 8497c168..e7c2ab03 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -1,6 +1,6 @@ services: traefik: - image: traefik:v3.4 + image: traefik:v3.6 container_name: claudex-traefik restart: always command: @@ -241,3 +241,4 @@ volumes: driver: local storage_data: driver: local + From c784e0347607ac9dfd4b3d002cc2769db689914a Mon Sep 17 00:00:00 2001 From: Bobby Hyam Date: Sun, 8 Feb 2026 15:22:01 +0000 Subject: [PATCH 7/9] Convert Traefik CLI args to environment variables Environment variables are the preferred configuration method for Traefik in Docker Compose, avoiding shell escaping issues with CLI args. Co-Authored-By: Claude Opus 4.6 --- docker-compose.traefik.yml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index e7c2ab03..76aea949 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -3,20 +3,20 @@ services: image: traefik:v3.6 container_name: claudex-traefik restart: always - command: - - "--ping=true" - - "--api.dashboard=false" - - "--providers.docker=true" - - "--providers.docker.exposedbydefault=false" - - "--providers.docker.network=claudex-proxy" - - "--entrypoints.web.address=:80" - - "--entrypoints.websecure.address=:443" - - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - - "--entrypoints.web.http.redirections.entrypoint.scheme=https" - - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" - - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" - - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + environment: + - TRAEFIK_PING=true + - TRAEFIK_API_DASHBOARD=false + - TRAEFIK_PROVIDERS_DOCKER=true + - TRAEFIK_PROVIDERS_DOCKER_EXPOSEDBYDEFAULT=false + - TRAEFIK_PROVIDERS_DOCKER_NETWORK=claudex-proxy + - TRAEFIK_ENTRYPOINTS_WEB_ADDRESS=:80 + - TRAEFIK_ENTRYPOINTS_WEBSECURE_ADDRESS=:443 + - TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_TO=websecure + - TRAEFIK_ENTRYPOINTS_WEB_HTTP_REDIRECTIONS_ENTRYPOINT_SCHEME=https + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE=true + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE_ENTRYPOINT=web + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=${ACME_EMAIL} + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_STORAGE=/letsencrypt/acme.json ports: - "80:80" - "443:443" From 26bfe861a62c70ed8dd8feef8d33f3c1626bda64 Mon Sep 17 00:00:00 2001 From: Bobby Hyam Date: Sun, 8 Feb 2026 15:28:31 +0000 Subject: [PATCH 8/9] Remove simple compose config, update README deployment docs Drop docker-compose.simple.yml from the repo and rewrite the deployment section to clarify the two supported modes (localhost dev vs internet-facing server) and the current requirement for public domain with wildcard DNS. Co-Authored-By: Claude Opus 4.6 --- README.md | 15 ++- docker-compose.simple.yml | 192 -------------------------------------- 2 files changed, 6 insertions(+), 201 deletions(-) delete mode 100644 docker-compose.simple.yml diff --git a/README.md b/README.md index c0852533..d6db5a58 100644 --- a/README.md +++ b/README.md @@ -200,17 +200,14 @@ For production deployment on a VPS, see the [Coolify Installation Guide](docs/co ### Docker Compose -Two production-ready Docker Compose configurations are provided, both using pre-built images from GHCR. +**Prerequisites** +Currently, Claudex has two modes of deployment with Docker Compose: +- On your localhost (mainly used for development purposes) +- On an internet facing server -**Simple (direct port exposure):** +There is currently no configuration provided for deploying it into a private network and accessing it across the LAN without allowing inbound. This is because it relies on wildcard DNS and publicly signed SSL certificates. -```bash -cp .env.simple.example .env -# Edit .env — set SECRET_KEY at minimum -docker compose -f docker-compose.simple.yml up -d -``` - -Frontend at `http://localhost:3000`, API at `http://localhost:8080`. +In the future the plan is to add a configuration for a local, private deployment. For now, you must have a public domain with wildcard DNS support and port 80 and 443 open to the compose stack. **Traefik (HTTPS with Let's Encrypt):** diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml deleted file mode 100644 index e87e8c19..00000000 --- a/docker-compose.simple.yml +++ /dev/null @@ -1,192 +0,0 @@ -services: - postgres: - image: postgres:17-alpine - container_name: claudex-postgres - restart: always - command: -c max_connections=600 - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB: ${POSTGRES_DB:-claudex} - ports: - - "${POSTGRES_PORT:-5432}:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - claudex-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - deploy: - resources: - limits: - memory: 2G - - redis: - image: redis:7-alpine - container_name: claudex-redis - restart: always - command: redis-server --appendonly yes - ports: - - "${REDIS_PORT:-6379}:6379" - volumes: - - redis_data:/data - networks: - - claudex-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - deploy: - resources: - limits: - memory: 512M - - sandbox-setup: - image: docker:27-cli - container_name: claudex-sandbox-setup - restart: "no" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - command: sh -c "docker pull ${DOCKER_IMAGE:-ghcr.io/mng-dev-ai/claudex-sandbox:latest}" - networks: - - claudex-network - - api: - image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-backend:latest - container_name: claudex-api - restart: unless-stopped - command: ["api"] - user: root - privileged: true - group_add: - - "0" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - sandbox-setup: - condition: service_completed_successfully - ports: - - "${BACKEND_PORT:-8080}:8080" - volumes: - - storage_data:/app/storage - - /var/run/docker.sock:/var/run/docker.sock - environment: - - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-claudex} - - REDIS_URL=redis://redis:6379/0 - - SECRET_KEY=${SECRET_KEY:-claudex_default_secret_key_for_development_only} - - PYTHONUNBUFFERED=1 - - LOG_LEVEL=${LOG_LEVEL:-INFO} - networks: - - claudex-network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - deploy: - resources: - limits: - memory: 4G - - celery-beat: - image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-backend:latest - container_name: claudex-celery-beat - restart: unless-stopped - command: ["celery-beat"] - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - volumes: - - storage_data:/app/storage - environment: - - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-claudex} - - REDIS_URL=redis://redis:6379/0 - - SECRET_KEY=${SECRET_KEY:-claudex_default_secret_key_for_development_only} - - PYTHONUNBUFFERED=1 - - LOG_LEVEL=${LOG_LEVEL:-INFO} - networks: - - claudex-network - deploy: - resources: - limits: - memory: 512M - - celery-worker: - image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-backend:latest - restart: unless-stopped - command: ["celery-worker"] - user: root - privileged: true - group_add: - - "0" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - sandbox-setup: - condition: service_completed_successfully - volumes: - - storage_data:/app/storage - - /var/run/docker.sock:/var/run/docker.sock - environment: - - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-claudex} - - REDIS_URL=redis://redis:6379/0 - - SECRET_KEY=${SECRET_KEY:-claudex_default_secret_key_for_development_only} - - PYTHONUNBUFFERED=1 - - LOG_LEVEL=${LOG_LEVEL:-INFO} - - CELERY_CONCURRENCY=${CELERY_CONCURRENCY:-25} - networks: - - claudex-network - deploy: - replicas: ${CELERY_WORKER_REPLICAS:-8} - resources: - limits: - memory: 4G - - frontend: - image: ghcr.io/${IMAGE_OWNER:-mng-dev-ai}/claudex-frontend:latest - container_name: claudex-frontend - restart: unless-stopped - depends_on: - - api - ports: - - "${FRONTEND_PORT:-3000}:80" - environment: - - VITE_API_BASE_URL=http://localhost:${BACKEND_PORT:-8080}/api/v1 - - VITE_WS_URL=ws://localhost:${BACKEND_PORT:-8080}/api/v1/ws - networks: - - claudex-network - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - deploy: - resources: - limits: - memory: 256M - -networks: - claudex-network: - driver: bridge - -volumes: - postgres_data: - driver: local - redis_data: - driver: local - storage_data: - driver: local From 08a5802adbf9d88cf45bdb9abc40903c2b9c87c7 Mon Sep 17 00:00:00 2001 From: Bobby Hyam Date: Sun, 8 Feb 2026 15:29:48 +0000 Subject: [PATCH 9/9] Remove .env.simple.example from repo No longer needed after dropping the simple compose configuration. Co-Authored-By: Claude Opus 4.6 --- .env.simple.example | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 .env.simple.example diff --git a/.env.simple.example b/.env.simple.example deleted file mode 100644 index 7a8a51bc..00000000 --- a/.env.simple.example +++ /dev/null @@ -1,26 +0,0 @@ -# Security (required for production - generate with: openssl rand -hex 32) -SECRET_KEY=claudex_default_secret_key_for_development_only - -# Database -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres -POSTGRES_DB=claudex - -# Ports (change if needed) -BACKEND_PORT=8080 -FRONTEND_PORT=3000 -POSTGRES_PORT=5432 -REDIS_PORT=6379 - -# Docker image owner (change to your GitHub username for fork builds) -IMAGE_OWNER=mng-dev-ai - -# Docker sandbox image -DOCKER_IMAGE=ghcr.io/mng-dev-ai/claudex-sandbox:latest - -# Celery workers -CELERY_CONCURRENCY=25 -CELERY_WORKER_REPLICAS=8 - -# Logging -LOG_LEVEL=INFO