diff --git a/.env.traefik.example b/.env.traefik.example new file mode 100644 index 00000000..e06515a7 --- /dev/null +++ b/.env.traefik.example @@ -0,0 +1,24 @@ +# 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 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 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/README.md b/README.md index 757e1b49..ec10fe30 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,36 @@ docker compose -p claudex-web -f docker-compose.yml logs -f # Web logs For production deployment on a VPS, see the [Coolify Installation Guide](docs/coolify-installation-guide.md). +### Docker Compose + +**Prerequisites** +Currently, Claudex has two modes of deployment with Docker Compose: +- On your localhost (mainly used for development purposes) +- On an internet facing server + +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. + +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):** + +```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 diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml new file mode 100644 index 00000000..76aea949 --- /dev/null +++ b/docker-compose.traefik.yml @@ -0,0 +1,244 @@ +services: + traefik: + image: traefik:v3.6 + container_name: claudex-traefik + restart: always + 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" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik_letsencrypt:/letsencrypt + networks: + - claudex-proxy + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + 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: + 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 + 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} + - ALLOWED_ORIGINS=https://${DOMAIN} + 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: + 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: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: + 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: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: + 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: + - "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://127.0.0.1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + memory: 256M + +networks: + claudex-proxy: + name: 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..c54e8171 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,35 @@ +# Stage 1: Build +FROM node:20-alpine AS builder + +WORKDIR /app + +# 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 + +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 + +# 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 "$@" 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; + } +}