feat: add Caddy reverse proxy with auto HTTPS for LAN access and auto-derive WebSocket URL (#863)

* 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 <igor.loskutoff@gmail.com>
This commit is contained in:
2026-02-13 14:21:43 -06:00
committed by GitHub
parent 14a8b5808e
commit 7f2a4013cb
8 changed files with 233 additions and 110 deletions

View File

@@ -1,11 +1,142 @@
# Standalone services for fully local deployment (no external dependencies). # Self-contained standalone compose for fully local deployment (no external dependencies).
# Usage: docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d # Usage: docker compose -f docker-compose.standalone.yml up -d
# #
# On Linux with NVIDIA GPU, also pass: --profile ollama-gpu # On Linux with NVIDIA GPU, also pass: --profile ollama-gpu
# On Linux without GPU: --profile ollama-cpu # On Linux without GPU: --profile ollama-cpu
# On Mac: Ollama runs natively (Metal GPU) — no profile needed, services here unused. # On Mac: Ollama runs natively (Metal GPU) — no profile needed, services here unused.
services: 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: garage:
image: dxflrs/garage:v1.1.0 image: dxflrs/garage:v1.1.0
ports: ports:
@@ -23,102 +154,6 @@ services:
retries: 5 retries: 5
start_period: 5s 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: cpu:
build: build:
context: ./gpu/self_hosted context: ./gpu/self_hosted
@@ -156,8 +191,45 @@ services:
retries: 10 retries: 10
start_period: 120s 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: volumes:
garage_data: garage_data:
garage_meta: garage_meta:
ollama_data: ollama_data:
gpu_cache: gpu_cache:
caddy_data:
caddy_config:

View File

@@ -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` 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` 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 ## What's NOT covered

View File

@@ -148,7 +148,7 @@ resolve_symlink() {
} }
compose_cmd() { 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 if [[ "$OS" == "Linux" ]] && [[ -n "${OLLAMA_PROFILE:-}" ]]; then
docker compose $compose_files --profile "$OLLAMA_PROFILE" "$@" docker compose $compose_files --profile "$OLLAMA_PROFILE" "$@"
else else
@@ -362,7 +362,7 @@ step_services() {
# Check for port conflicts — stale processes silently shadow Docker port mappings. # Check for port conflicts — stale processes silently shadow Docker port mappings.
# OrbStack/Docker Desktop bind ports for forwarding; ignore those PIDs. # OrbStack/Docker Desktop bind ports for forwarding; ignore those PIDs.
local ports_ok=true 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 local pids
pids=$(lsof -ti :"$port" 2>/dev/null || true) pids=$(lsof -ti :"$port" 2>/dev/null || true)
for pid in $pids; do for pid in $pids; do
@@ -386,7 +386,7 @@ step_services() {
rebuild_images rebuild_images
# server runs alembic migrations on startup automatically (see runserver.sh) # 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" ok "Containers started"
# Quick sanity check — catch containers that exit immediately (bad image, missing file, etc.) # Quick sanity check — catch containers that exit immediately (bad image, missing file, etc.)
@@ -464,6 +464,14 @@ step_health() {
echo "" echo ""
ok "Frontend responding" 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 # Check LLM reachability from inside a container
if compose_cmd exec -T server \ if compose_cmd exec -T server \
curl -sf "$LLM_URL_VALUE/models" > /dev/null 2>&1; then curl -sf "$LLM_URL_VALUE/models" > /dev/null 2>&1; then
@@ -533,8 +541,8 @@ main() {
echo -e " ${GREEN}Reflector is running!${NC}" echo -e " ${GREEN}Reflector is running!${NC}"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo " Frontend: http://localhost:3000" echo " App: https://localhost:3043 (accept self-signed cert in browser)"
echo " API: http://localhost:1250" echo " API: https://localhost:3043/server-api"
echo "" echo ""
echo " To stop: docker compose down" echo " To stop: docker compose down"
echo " To re-run: ./scripts/setup-standalone.sh" echo " To re-run: ./scripts/setup-standalone.sh"

View File

@@ -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
}
}

View File

@@ -59,7 +59,7 @@ else:
logger.info("Sentry disabled") logger.info("Sentry disabled")
# build app # build app
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan, root_path=settings.ROOT_PATH)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_credentials=settings.CORS_ALLOW_CREDENTIALS or False, allow_credentials=settings.CORS_ALLOW_CREDENTIALS or False,

View File

@@ -12,6 +12,8 @@ class Settings(BaseSettings):
extra="ignore", extra="ignore",
) )
ROOT_PATH: str = "/"
# CORS # CORS
UI_BASE_URL: str = "http://localhost:3000" UI_BASE_URL: str = "http://localhost:3000"
CORS_ORIGIN: str = "*" CORS_ORIGIN: str = "*"

View File

@@ -13,9 +13,33 @@ export const API_URL = !isBuildPhase
? getClientEnv().API_URL ? getClientEnv().API_URL
: "http://localhost"; : "http://localhost";
export const WEBSOCKET_URL = !isBuildPhase /**
? getClientEnv().WEBSOCKET_URL || "ws://127.0.0.1:1250" * Derive a WebSocket URL from the API_URL.
: "ws://localhost"; * 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<paths>({ export const client = createClient<paths>({
baseUrl: API_URL, baseUrl: API_URL,

View File

@@ -2,6 +2,7 @@ import {
assertExists, assertExists,
assertExistsAndNonEmptyString, assertExistsAndNonEmptyString,
NonEmptyString, NonEmptyString,
parseMaybeNonEmptyString,
parseNonEmptyString, parseNonEmptyString,
} from "./utils"; } from "./utils";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
@@ -74,14 +75,14 @@ export const getClientEnvServer = (): ClientEnvCommon => {
if (isBuildPhase) { if (isBuildPhase) {
return { return {
API_URL: getNextEnvVar("API_URL"), API_URL: getNextEnvVar("API_URL"),
WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"), WEBSOCKET_URL: parseMaybeNonEmptyString(process.env.WEBSOCKET_URL ?? ""),
...features, ...features,
}; };
} }
clientEnv = { clientEnv = {
API_URL: getNextEnvVar("API_URL"), API_URL: getNextEnvVar("API_URL"),
WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"), WEBSOCKET_URL: parseMaybeNonEmptyString(process.env.WEBSOCKET_URL ?? ""),
...features, ...features,
}; };
return clientEnv; return clientEnv;