From 7f2a4013cbb3d3ee3e76885f28d73331dcaf325c Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Fri, 13 Feb 2026 14:21:43 -0600 Subject: [PATCH] feat: add Caddy reverse proxy with auto HTTPS for LAN access and auto-derive WebSocket URL (#863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Caddy reverse proxy with auto HTTPS for LAN access and auto-derive WebSocket URL Add a Caddy service to docker-compose.standalone.yml that provides automatic HTTPS with local certificates, enabling secure access to both the frontend and API from the local network through a single entrypoint. Backend changes: - Add ROOT_PATH setting to FastAPI so the API can be served under /api prefix - Route frontend and API (/server-api) through Caddy reverse proxy Frontend changes: - Support WEBSOCKET_URL=auto to derive the WebSocket URL from API_URL automatically, using the page protocol (http→ws, https→wss) and host - Make WEBSOCKET_URL env var optional instead of required * style: pre-commit * fix: make standalone compose self-contained (drop !reset dependency) docker-compose.standalone.yml used !reset YAML tags to clear network_mode and volumes from the base compose. !reset requires Compose v2.24+ and breaks on Colima + brew-installed compose. Rewrite as a fully self-contained file with all services defined directly (server, worker, beat, redis, postgres, web, garage, cpu, gpu-nvidia, ollama, ollama-cpu). No longer overlays docker-compose.yml. Update setup-standalone.sh compose_cmd() to use only the standalone file instead of both files. * fix: update standalone docs to match self-contained compose usage --------- Co-authored-by: Igor Loskutov --- docker-compose.standalone.yml | 268 +++++++++++++-------- docs/docs/installation/setup-standalone.md | 2 +- scripts/setup-standalone.sh | 18 +- scripts/standalone/Caddyfile | 16 ++ server/reflector/app.py | 2 +- server/reflector/settings.py | 2 + www/app/lib/apiClient.tsx | 30 ++- www/app/lib/clientEnv.ts | 5 +- 8 files changed, 233 insertions(+), 110 deletions(-) create mode 100644 scripts/standalone/Caddyfile diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml index 92ab9e58..dbe96347 100644 --- a/docker-compose.standalone.yml +++ b/docker-compose.standalone.yml @@ -1,11 +1,142 @@ -# Standalone services for fully local deployment (no external dependencies). -# Usage: docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d +# Self-contained standalone compose for fully local deployment (no external dependencies). +# Usage: docker compose -f docker-compose.standalone.yml up -d # # On Linux with NVIDIA GPU, also pass: --profile ollama-gpu # On Linux without GPU: --profile ollama-cpu # On Mac: Ollama runs natively (Metal GPU) — no profile needed, services here unused. services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "3043:443" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./scripts/standalone/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + + server: + build: + context: server + ports: + - "1250:1250" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./server/:/app/ + - /app/.venv + env_file: + - ./server/.env + environment: + ENTRYPOINT: server + # Docker DNS names instead of localhost + DATABASE_URL: postgresql+asyncpg://reflector:reflector@postgres:5432/reflector + REDIS_HOST: redis + CELERY_BROKER_URL: redis://redis:6379/1 + CELERY_RESULT_BACKEND: redis://redis:6379/1 + # Standalone doesn't run Hatchet + HATCHET_CLIENT_SERVER_URL: "" + HATCHET_CLIENT_HOST_PORT: "" + # Self-hosted transcription/diarization via CPU service + TRANSCRIPT_BACKEND: modal + TRANSCRIPT_URL: http://cpu:8000 + TRANSCRIPT_MODAL_API_KEY: local + DIARIZATION_BACKEND: modal + DIARIZATION_URL: http://cpu:8000 + # Caddy reverse proxy prefix + ROOT_PATH: /server-api + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + + worker: + build: + context: server + volumes: + - ./server/:/app/ + - /app/.venv + env_file: + - ./server/.env + environment: + ENTRYPOINT: worker + HATCHET_CLIENT_SERVER_URL: "" + HATCHET_CLIENT_HOST_PORT: "" + TRANSCRIPT_BACKEND: modal + TRANSCRIPT_URL: http://cpu:8000 + TRANSCRIPT_MODAL_API_KEY: local + DIARIZATION_BACKEND: modal + DIARIZATION_URL: http://cpu:8000 + depends_on: + redis: + condition: service_started + + beat: + build: + context: server + volumes: + - ./server/:/app/ + - /app/.venv + env_file: + - ./server/.env + environment: + ENTRYPOINT: beat + depends_on: + redis: + condition: service_started + + redis: + image: redis:7.2 + ports: + - 6379:6379 + + postgres: + image: postgres:17 + command: postgres -c 'max_connections=200' + ports: + - 5432:5432 + environment: + POSTGRES_USER: reflector + POSTGRES_PASSWORD: reflector + POSTGRES_DB: reflector + volumes: + - ./data/postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -d reflector -U reflector"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 15s + + web: + image: reflector-frontend-standalone + build: + context: ./www + ports: + - "3000:3000" + command: ["node", "server.js"] + environment: + NODE_ENV: production + # Browser-facing URLs (host-accessible ports) + API_URL: /server-api + WEBSOCKET_URL: auto + SITE_URL: http://localhost:3000 + # Server-side URLs (docker-network internal) + SERVER_API_URL: http://server:1250 + KV_URL: redis://redis:6379 + KV_USE_TLS: "false" + # Standalone: no external auth provider + FEATURE_REQUIRE_LOGIN: "false" + NEXTAUTH_URL: http://localhost:3000 + NEXTAUTH_SECRET: standalone-local-secret + # Nullify partial auth vars inherited from base env_file + AUTHENTIK_ISSUER: "" + AUTHENTIK_REFRESH_TOKEN_URL: "" + garage: image: dxflrs/garage:v1.1.0 ports: @@ -23,102 +154,6 @@ services: retries: 5 start_period: 5s - ollama: - image: ollama/ollama:latest - profiles: ["ollama-gpu"] - ports: - - "11434:11434" - volumes: - - ollama_data:/root/.ollama - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] - interval: 10s - timeout: 5s - retries: 5 - - ollama-cpu: - image: ollama/ollama:latest - profiles: ["ollama-cpu"] - ports: - - "11434:11434" - volumes: - - ollama_data:/root/.ollama - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] - interval: 10s - timeout: 5s - retries: 5 - - # Override server to use standard compose networking instead of network_mode:host. - # host mode breaks on macOS Docker Desktop and prevents Docker DNS resolution. - server: - network_mode: !reset null - ports: - - "1250:1250" - extra_hosts: - - "host.docker.internal:host-gateway" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_started - environment: - # Override base compose's localhost URLs with Docker DNS names - DATABASE_URL: postgresql+asyncpg://reflector:reflector@postgres:5432/reflector - REDIS_HOST: redis - CELERY_BROKER_URL: redis://redis:6379/1 - CELERY_RESULT_BACKEND: redis://redis:6379/1 - # Standalone doesn't run Hatchet — blank out localhost URLs inherited from base - HATCHET_CLIENT_SERVER_URL: "" - HATCHET_CLIENT_HOST_PORT: "" - # Self-hosted transcription/diarization via CPU service - TRANSCRIPT_BACKEND: modal - TRANSCRIPT_URL: http://cpu:8000 - TRANSCRIPT_MODAL_API_KEY: local - DIARIZATION_BACKEND: modal - DIARIZATION_URL: http://cpu:8000 - - worker: - environment: - TRANSCRIPT_BACKEND: modal - TRANSCRIPT_URL: http://cpu:8000 - TRANSCRIPT_MODAL_API_KEY: local - DIARIZATION_BACKEND: modal - DIARIZATION_URL: http://cpu:8000 - - web: - image: reflector-frontend-standalone - build: - context: ./www - command: ["node", "server.js"] - volumes: !reset [] - environment: - NODE_ENV: production - # Browser-facing URLs (host-accessible ports) - API_URL: http://localhost:1250 - WEBSOCKET_URL: ws://localhost:1250 - SITE_URL: http://localhost:3000 - # Server-side URLs (docker-network internal) - SERVER_API_URL: http://server:1250 - KV_URL: redis://redis:6379 - KV_USE_TLS: "false" - # Standalone: no external auth provider - FEATURE_REQUIRE_LOGIN: "false" - NEXTAUTH_URL: http://localhost:3000 - NEXTAUTH_SECRET: standalone-local-secret - # Nullify partial auth vars inherited from base env_file - AUTHENTIK_ISSUER: "" - AUTHENTIK_REFRESH_TOKEN_URL: "" - cpu: build: context: ./gpu/self_hosted @@ -156,8 +191,45 @@ services: retries: 10 start_period: 120s + ollama: + image: ollama/ollama:latest + profiles: ["ollama-gpu"] + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] + interval: 10s + timeout: 5s + retries: 5 + + ollama-cpu: + image: ollama/ollama:latest + profiles: ["ollama-cpu"] + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] + interval: 10s + timeout: 5s + retries: 5 + volumes: garage_data: garage_meta: ollama_data: gpu_cache: + caddy_data: + caddy_config: diff --git a/docs/docs/installation/setup-standalone.md b/docs/docs/installation/setup-standalone.md index 320ca3fe..ea9c581f 100644 --- a/docs/docs/installation/setup-standalone.md +++ b/docs/docs/installation/setup-standalone.md @@ -191,7 +191,7 @@ Standalone runs without authentication (`FEATURE_REQUIRE_LOGIN=false`, `AUTH_BAC 1. In `www/.env.local`: set `FEATURE_REQUIRE_LOGIN=true`, uncomment `AUTHENTIK_ISSUER` and `AUTHENTIK_REFRESH_TOKEN_URL` 2. In `server/.env`: set `AUTH_BACKEND=authentik` (or your backend), configure `AUTH_JWT_AUDIENCE` -3. Restart: `docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d --force-recreate web server` +3. Restart: `docker compose -f docker-compose.standalone.yml up -d --force-recreate web server` ## What's NOT covered diff --git a/scripts/setup-standalone.sh b/scripts/setup-standalone.sh index 0d08c581..35ad0445 100755 --- a/scripts/setup-standalone.sh +++ b/scripts/setup-standalone.sh @@ -148,7 +148,7 @@ resolve_symlink() { } compose_cmd() { - local compose_files="-f $ROOT_DIR/docker-compose.yml -f $ROOT_DIR/docker-compose.standalone.yml" + local compose_files="-f $ROOT_DIR/docker-compose.standalone.yml" if [[ "$OS" == "Linux" ]] && [[ -n "${OLLAMA_PROFILE:-}" ]]; then docker compose $compose_files --profile "$OLLAMA_PROFILE" "$@" else @@ -362,7 +362,7 @@ step_services() { # Check for port conflicts — stale processes silently shadow Docker port mappings. # OrbStack/Docker Desktop bind ports for forwarding; ignore those PIDs. local ports_ok=true - for port in 3000 1250 5432 6379 3900 3903; do + for port in 3043 3000 1250 5432 6379 3900 3903; do local pids pids=$(lsof -ti :"$port" 2>/dev/null || true) for pid in $pids; do @@ -386,7 +386,7 @@ step_services() { rebuild_images # server runs alembic migrations on startup automatically (see runserver.sh) - compose_cmd up -d postgres redis garage cpu server worker beat web + compose_cmd up -d postgres redis garage cpu server worker beat web caddy ok "Containers started" # Quick sanity check — catch containers that exit immediately (bad image, missing file, etc.) @@ -464,6 +464,14 @@ step_health() { echo "" ok "Frontend responding" + # Caddy reverse proxy (self-signed TLS — curl needs -k) + if curl -sfk "https://localhost:3043" > /dev/null 2>&1; then + ok "Caddy proxy healthy (https://localhost:3043)" + else + warn "Caddy proxy not responding on https://localhost:3043" + warn "Check with: docker compose logs caddy" + fi + # Check LLM reachability from inside a container if compose_cmd exec -T server \ curl -sf "$LLM_URL_VALUE/models" > /dev/null 2>&1; then @@ -533,8 +541,8 @@ main() { echo -e " ${GREEN}Reflector is running!${NC}" echo "==========================================" echo "" - echo " Frontend: http://localhost:3000" - echo " API: http://localhost:1250" + echo " App: https://localhost:3043 (accept self-signed cert in browser)" + echo " API: https://localhost:3043/server-api" echo "" echo " To stop: docker compose down" echo " To re-run: ./scripts/setup-standalone.sh" diff --git a/scripts/standalone/Caddyfile b/scripts/standalone/Caddyfile new file mode 100644 index 00000000..0b8ac18b --- /dev/null +++ b/scripts/standalone/Caddyfile @@ -0,0 +1,16 @@ +# Standalone Caddyfile — single-origin reverse proxy for local development. +# Routes: +# /server-api/* → FastAPI backend (server:1250) +# /* → Next.js frontend (web:3000) + +localhost { + # API + WebSocket: server has ROOT_PATH=/server-api so path is preserved + handle /server-api/* { + reverse_proxy server:1250 + } + + # Everything else → frontend + handle { + reverse_proxy web:3000 + } +} diff --git a/server/reflector/app.py b/server/reflector/app.py index 2ca76acb..fc7bcf8e 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -59,7 +59,7 @@ else: logger.info("Sentry disabled") # build app -app = FastAPI(lifespan=lifespan) +app = FastAPI(lifespan=lifespan, root_path=settings.ROOT_PATH) app.add_middleware( CORSMiddleware, allow_credentials=settings.CORS_ALLOW_CREDENTIALS or False, diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 805239bc..b3acccd9 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -12,6 +12,8 @@ class Settings(BaseSettings): extra="ignore", ) + ROOT_PATH: str = "/" + # CORS UI_BASE_URL: str = "http://localhost:3000" CORS_ORIGIN: str = "*" diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx index 442d2f42..de45dbea 100644 --- a/www/app/lib/apiClient.tsx +++ b/www/app/lib/apiClient.tsx @@ -13,9 +13,33 @@ export const API_URL = !isBuildPhase ? getClientEnv().API_URL : "http://localhost"; -export const WEBSOCKET_URL = !isBuildPhase - ? getClientEnv().WEBSOCKET_URL || "ws://127.0.0.1:1250" - : "ws://localhost"; +/** + * Derive a WebSocket URL from the API_URL. + * Handles full URLs (http://host/api, https://host/api) and relative paths (/api). + * For full URLs, ws/wss is derived from the URL's own protocol. + * For relative URLs, ws/wss is derived from window.location.protocol. + */ +const deriveWebSocketUrl = (apiUrl: string): string => { + if (typeof window === "undefined") { + return "ws://localhost"; + } + const parsed = new URL(apiUrl, window.location.origin); + const wsProtocol = parsed.protocol === "https:" ? "wss:" : "ws:"; + // Normalize: remove trailing slash from pathname + const pathname = parsed.pathname.replace(/\/+$/, ""); + return `${wsProtocol}//${parsed.host}${pathname}`; +}; + +const resolveWebSocketUrl = (): string => { + if (isBuildPhase) return "ws://localhost"; + const raw = getClientEnv().WEBSOCKET_URL; + if (!raw || raw === "auto") { + return deriveWebSocketUrl(API_URL); + } + return raw; +}; + +export const WEBSOCKET_URL = resolveWebSocketUrl(); export const client = createClient({ baseUrl: API_URL, diff --git a/www/app/lib/clientEnv.ts b/www/app/lib/clientEnv.ts index 2c4a01c8..d8807871 100644 --- a/www/app/lib/clientEnv.ts +++ b/www/app/lib/clientEnv.ts @@ -2,6 +2,7 @@ import { assertExists, assertExistsAndNonEmptyString, NonEmptyString, + parseMaybeNonEmptyString, parseNonEmptyString, } from "./utils"; import { isBuildPhase } from "./next"; @@ -74,14 +75,14 @@ export const getClientEnvServer = (): ClientEnvCommon => { if (isBuildPhase) { return { API_URL: getNextEnvVar("API_URL"), - WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"), + WEBSOCKET_URL: parseMaybeNonEmptyString(process.env.WEBSOCKET_URL ?? ""), ...features, }; } clientEnv = { API_URL: getNextEnvVar("API_URL"), - WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"), + WEBSOCKET_URL: parseMaybeNonEmptyString(process.env.WEBSOCKET_URL ?? ""), ...features, }; return clientEnv;