diff --git a/.env.example b/.env.example index e2300a0..dd809b5 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,22 @@ # API CONFIGURATION # URL where the API can be accessed by the browser -# Default: http://localhost:5055 (works for most Docker setups) -# Change this if you're running on a different host/port (e.g., http://your-server-ip:5055) +# This setting allows the frontend to connect to the API at runtime (no rebuild needed!) +# +# IMPORTANT: Do NOT include /api at the end - it will be added automatically! +# +# Common scenarios: +# - Docker on localhost: http://localhost:5055 (default, works for most cases) +# - Docker on LAN/remote server: http://192.168.1.100:5055 or http://your-server-ip:5055 +# - Behind reverse proxy with custom domain: https://your-domain.com +# - Behind reverse proxy with subdomain: https://api.your-domain.com +# +# Examples for reverse proxy users: +# - API_URL=https://notebook.example.com (frontend will call https://notebook.example.com/api/*) +# - API_URL=https://api.example.com (frontend will call https://api.example.com/api/*) +# +# Note: If not set, the system will auto-detect based on the incoming request. +# Only set this if you need to override the auto-detection (e.g., reverse proxy scenarios). API_URL=http://localhost:5055 # SECURITY diff --git a/Dockerfile b/Dockerfile index 047f248..2f59089 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,7 +84,14 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf # Create log directories RUN mkdir -p /var/log/supervisor -# No default API_URL - the API will auto-detect from incoming requests -# Users can still override by setting API_URL environment variable if needed +# Runtime API URL Configuration +# The API_URL environment variable can be set at container runtime to configure +# where the frontend should connect to the API. This allows the same Docker image +# to work in different deployment scenarios without rebuilding. +# +# If not set, the system will auto-detect based on incoming requests. +# Set API_URL when using reverse proxies or custom domains. +# +# Example: docker run -e API_URL=https://your-domain.com/api ... CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/Dockerfile.single b/Dockerfile.single index c78c605..b99e7d4 100644 --- a/Dockerfile.single +++ b/Dockerfile.single @@ -88,7 +88,14 @@ COPY supervisord.single.conf /etc/supervisor/conf.d/supervisord.conf # Create log directories RUN mkdir -p /var/log/supervisor -# No default API_URL - the API will auto-detect from incoming requests -# Users can still override by setting API_URL environment variable if needed +# Runtime API URL Configuration +# The API_URL environment variable can be set at container runtime to configure +# where the frontend should connect to the API. This allows the same Docker image +# to work in different deployment scenarios without rebuilding. +# +# If not set, the system will auto-detect based on incoming requests. +# Set API_URL when using reverse proxies or custom domains. +# +# Example: docker run -e API_URL=https://your-domain.com/api ... CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file diff --git a/docs/deployment/index.md b/docs/deployment/index.md index 45055cd..60506c4 100644 --- a/docs/deployment/index.md +++ b/docs/deployment/index.md @@ -29,7 +29,14 @@ This section provides comprehensive guides for deploying Open Notebook in differ - Development tools and debugging - Contributing to the project -### 4. [Security Configuration](security.md) +### 4. [Reverse Proxy Configuration](reverse-proxy.md) +**For production deployments with custom domains** +- nginx, Caddy, Traefik configurations +- Custom domain setup +- SSL/HTTPS configuration +- Runtime API URL configuration + +### 5. [Security Configuration](security.md) **Essential for public deployments** - Password protection setup - Security best practices @@ -50,6 +57,12 @@ This section provides comprehensive guides for deploying Open Notebook in differ - You have resource constraints - You don't need to scale services independently +### Use Reverse Proxy Setup if: +- You're deploying with a custom domain +- You need HTTPS/SSL encryption +- You're using nginx, Caddy, or Traefik +- You want to expose only specific ports publicly + ### Use Development Setup if: - You want to contribute to the project - You need to modify the source code diff --git a/docs/deployment/reverse-proxy.md b/docs/deployment/reverse-proxy.md new file mode 100644 index 0000000..92faa76 --- /dev/null +++ b/docs/deployment/reverse-proxy.md @@ -0,0 +1,304 @@ +# Reverse Proxy Configuration + +This guide helps you deploy Open Notebook behind a reverse proxy (nginx, Caddy, Traefik, etc.) or with a custom domain. + +## The API_URL Environment Variable + +Starting with v1.0+, Open Notebook supports runtime configuration of the API URL through the `API_URL` environment variable. This means you can use the same Docker image in different deployment scenarios without rebuilding. + +### How It Works + +The frontend uses a three-tier priority system to determine the API URL: + +1. **Runtime Configuration** (Highest Priority): `API_URL` environment variable set at container runtime +2. **Build-time Configuration**: `NEXT_PUBLIC_API_URL` baked into the Docker image +3. **Auto-detection** (Fallback): Infers from the incoming HTTP request + +## Common Scenarios + +### Scenario 1: Docker on Localhost (Default) + +No configuration needed! The system auto-detects. + +```bash +docker run -d \ + --name open-notebook \ + -p 8502:8502 -p 5055:5055 \ + -v ./notebook_data:/app/data \ + -v ./surreal_data:/mydata \ + lfnovo/open_notebook:v1-latest-single +``` + +### Scenario 2: Docker on Remote Server (LAN/VPS) + +Access via IP address - auto-detection works, but you can be explicit: + +```bash +docker run -d \ + --name open-notebook \ + -p 8502:8502 -p 5055:5055 \ + -e API_URL=http://192.168.1.100:5055 \ + -v ./notebook_data:/app/data \ + -v ./surreal_data:/mydata \ + lfnovo/open_notebook:v1-latest-single +``` + +> **Note**: Don't include `/api` at the end - the system adds this automatically! + +### Scenario 3: Behind Reverse Proxy with Custom Domain + +This is where `API_URL` is **essential**. Your reverse proxy handles HTTPS and routing. + +> **Important**: If your reverse proxy forwards `/api` requests to the backend, set `API_URL` to just the domain (without `/api` suffix). The frontend will append `/api` automatically. + +#### Example: nginx + Docker Compose + +**docker-compose.yml:** +```yaml +version: '3.8' + +services: + open-notebook: + image: lfnovo/open_notebook:v1-latest-single + container_name: open-notebook + environment: + - API_URL=https://notebook.example.com + - OPENAI_API_KEY=${OPENAI_API_KEY} + volumes: + - ./notebook_data:/app/data + - ./surreal_data:/mydata + ports: + - "8502:8502" # Frontend + - "5055:5055" # API + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: nginx-proxy + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./ssl:/etc/nginx/ssl:ro + depends_on: + - open-notebook + restart: unless-stopped +``` + +**nginx.conf:** +```nginx +http { + upstream frontend { + server open-notebook:8502; + } + + upstream api { + server open-notebook:5055; + } + + server { + listen 80; + server_name notebook.example.com; + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl http2; + server_name notebook.example.com; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + # Frontend + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # API + location /api/ { + proxy_pass http://api/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} +``` + +### Scenario 4: Behind Reverse Proxy with Subdomain + +If you want API on a separate subdomain: + +**docker-compose.yml:** +```yaml +services: + open-notebook: + image: lfnovo/open_notebook:v1-latest-single + environment: + - API_URL=https://api.notebook.example.com + # ... other env vars +``` + +**nginx.conf:** +```nginx +# Frontend server +server { + listen 443 ssl http2; + server_name notebook.example.com; + + location / { + proxy_pass http://open-notebook:8502; + # ... proxy headers + } +} + +# API server +server { + listen 443 ssl http2; + server_name api.notebook.example.com; + + location / { + proxy_pass http://open-notebook:5055; + # ... proxy headers + } +} +``` + +### Scenario 5: Traefik + +**docker-compose.yml:** +```yaml +version: '3.8' + +services: + open-notebook: + image: lfnovo/open_notebook:v1-latest-single + environment: + - API_URL=https://notebook.example.com + labels: + # Frontend + - "traefik.enable=true" + - "traefik.http.routers.notebook-frontend.rule=Host(`notebook.example.com`)" + - "traefik.http.routers.notebook-frontend.entrypoints=websecure" + - "traefik.http.routers.notebook-frontend.tls.certresolver=myresolver" + - "traefik.http.services.notebook-frontend.loadbalancer.server.port=8502" + + # API + - "traefik.http.routers.notebook-api.rule=Host(`notebook.example.com`) && PathPrefix(`/api`)" + - "traefik.http.routers.notebook-api.entrypoints=websecure" + - "traefik.http.routers.notebook-api.tls.certresolver=myresolver" + - "traefik.http.services.notebook-api.loadbalancer.server.port=5055" + networks: + - traefik-network + +networks: + traefik-network: + external: true +``` + +### Scenario 6: Caddy + +**Caddyfile:** +```caddy +notebook.example.com { + # Frontend + reverse_proxy / open-notebook:8502 + + # API + reverse_proxy /api/* open-notebook:5055 +} +``` + +**docker-compose.yml:** +```yaml +services: + open-notebook: + image: lfnovo/open_notebook:v1-latest-single + environment: + - API_URL=https://notebook.example.com + # No need to expose ports if using Caddy in same network +``` + +## Troubleshooting + +### Connection Error: Unable to connect to server + +**Symptoms**: Frontend displays "Unable to connect to server. Please check if the API is running." + +**Possible Causes**: + +1. **API_URL not set correctly** for your reverse proxy setup + - Check browser console (F12) for connection errors + - Look for logs showing what URL the frontend is trying + +2. **Reverse proxy not forwarding to correct port** + - API should be accessible at the URL specified in `API_URL` + - Test: `curl https://your-domain.com/api/config` should return JSON + +3. **CORS issues** + - Ensure `X-Forwarded-Proto` and `X-Forwarded-For` headers are set in proxy config + - Check API logs for CORS errors + +4. **SSL/TLS certificate issues** + - Ensure your reverse proxy has valid SSL certificates + - Mixed content errors (HTTPS frontend trying to reach HTTP API) + +### How to Debug + +1. **Check browser console** (F12 → Console tab): + - Look for messages starting with `🔧 [Config]` + - These show the configuration detection process + - You'll see which API URL is being used + +2. **Test API directly**: + ```bash + # Should return JSON config + curl https://your-domain.com/api/config + ``` + +3. **Check Docker logs**: + ```bash + docker logs open-notebook + ``` + - Look for frontend and API startup messages + - Check for connection errors + +4. **Verify environment variable**: + ```bash + docker exec open-notebook env | grep API_URL + ``` + +### Missing Authorization Header + +**Symptoms**: API returns `{"detail": "Missing authorization header"}` + +This happens when: +- You have set `OPEN_NOTEBOOK_PASSWORD` for authentication +- You're trying to access `/api/config` directly without logging in first + +**Solution**: This is expected behavior! The frontend handles this automatically. Just access the frontend URL and log in through the UI. + +## Best Practices + +1. **Always use HTTPS** in production with reverse proxies +2. **Set `API_URL` explicitly** when using reverse proxies to avoid auto-detection issues +3. **Use environment files** (`.env` or `docker.env`) to manage configuration +4. **Test your setup** by accessing the frontend and checking browser console logs +5. **Keep ports 5055 and 8502 accessible** from your reverse proxy container + +## Additional Resources + +- [Docker Deployment Guide](./docker.md) +- [Security Guide](./security.md) +- [Troubleshooting](../troubleshooting/common-issues.md) diff --git a/frontend/src/app/api/runtime-config/route.ts b/frontend/src/app/api/runtime-config/route.ts new file mode 100644 index 0000000..deb8811 --- /dev/null +++ b/frontend/src/app/api/runtime-config/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' + +/** + * Runtime Configuration Endpoint + * + * This endpoint provides server-side environment variables to the client at runtime. + * This solves the NEXT_PUBLIC_* limitation where variables are baked into the build. + * + * Users can now set API_URL in their docker.env and it will be picked up at runtime, + * allowing the same Docker image to work in different deployment scenarios. + */ +export async function GET() { + // Priority: + // 1. API_URL from environment (set by user at runtime) + // 2. NEXT_PUBLIC_API_URL from build time (fallback) + // 3. Default to localhost:5055 + const apiUrl = process.env.API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5055' + + return NextResponse.json({ + apiUrl, + }) +} diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index 0d50fbc..8b965dc 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -60,11 +60,30 @@ async function fetchConfig(): Promise { console.log('🔧 [Config] Starting configuration detection...') console.log('🔧 [Config] Build time:', BUILD_TIME) - // Try to get from environment variable first (for development) + // STEP 1: Try to get runtime config from Next.js server-side API route + // This allows API_URL to be set at runtime (not baked into build) + let runtimeApiUrl: string | null = null + try { + console.log('🔧 [Config] Attempting to fetch runtime config from Next.js API route...') + const runtimeResponse = await fetch('/api/runtime-config', { + cache: 'no-store', + }) + if (runtimeResponse.ok) { + const runtimeData = await runtimeResponse.json() + runtimeApiUrl = runtimeData.apiUrl + console.log('✅ [Config] Runtime API URL from server:', runtimeApiUrl) + } else { + console.log('⚠️ [Config] Runtime config endpoint returned status:', runtimeResponse.status) + } + } catch (error) { + console.log('⚠️ [Config] Could not fetch runtime config:', error) + } + + // STEP 2: Fallback to build-time environment variable const envApiUrl = process.env.NEXT_PUBLIC_API_URL console.log('🔧 [Config] NEXT_PUBLIC_API_URL from build:', envApiUrl || '(not set)') - // Smart default: infer API URL from current frontend URL + // STEP 3: Smart default - infer API URL from current frontend URL // If frontend is at http://10.20.30.20:8502, API should be at http://10.20.30.20:5055 let defaultApiUrl = 'http://localhost:5055' @@ -82,13 +101,16 @@ async function fetchConfig(): Promise { } } - // Use env var if available, otherwise smart default - const baseUrl = envApiUrl || defaultApiUrl + // Priority: Runtime config > Build-time env var > Smart default + const baseUrl = runtimeApiUrl || envApiUrl || defaultApiUrl console.log('🔧 [Config] Final base URL to try:', baseUrl) + console.log('🔧 [Config] Selection priority: runtime=' + (runtimeApiUrl ? '✅' : '❌') + + ', build-time=' + (envApiUrl ? '✅' : '❌') + + ', smart-default=' + (!runtimeApiUrl && !envApiUrl ? '✅' : '❌')) try { - console.log('🔧 [Config] Fetching runtime config from:', `${baseUrl}/api/config`) - // Try to fetch runtime config from API + console.log('🔧 [Config] Fetching backend config from:', `${baseUrl}/api/config`) + // Try to fetch runtime config from backend API const response = await fetch(`${baseUrl}/api/config`, { cache: 'no-store', }) diff --git a/pyproject.toml b/pyproject.toml index fe22779..f415e30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open-notebook" -version = "1.0.1" +version = "1.0.2" description = "An open source implementation of a research assistant, inspired by Google Notebook LM" authors = [ {name = "Luis Novo", email = "lfnovo@gmail.com"} diff --git a/supervisord.conf b/supervisord.conf index 4566751..91949c3 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -28,7 +28,7 @@ startsecs=3 [program:frontend] command=npm run start directory=/app/frontend -environment=NODE_ENV="production",PORT="8502" +environment=NODE_ENV="production",PORT="8502",API_URL="%(ENV_API_URL)s" stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr diff --git a/supervisord.single.conf b/supervisord.single.conf index b9fda4e..707ca8a 100644 --- a/supervisord.single.conf +++ b/supervisord.single.conf @@ -40,7 +40,7 @@ startsecs=3 [program:frontend] command=npm run start directory=/app/frontend -environment=NODE_ENV="production",PORT="8502" +environment=NODE_ENV="production",PORT="8502",API_URL="%(ENV_API_URL)s" stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr diff --git a/uv.lock b/uv.lock index 40956ff..bd43de8 100644 --- a/uv.lock +++ b/uv.lock @@ -620,15 +620,15 @@ wheels = [ [[package]] name = "esperanto" -version = "2.7.0" +version = "2.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/cf/0da02a603a63b3850abd14d23629f101942db5c18840b0cc6f34d7db9a04/esperanto-2.7.0.tar.gz", hash = "sha256:3861e4e20697813b19f0070a1142934bd6792077c3c174a2c3dd4b6ca0676b06", size = 553433, upload-time = "2025-10-19T02:04:30.21Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/8c/a2a9c1e7428d700963e7450aba591b2f4fbcfa61bad88654af8d92df3a00/esperanto-2.7.1.tar.gz", hash = "sha256:a18abc38d30d38c496eac62f252a9c12d243fc49fef7cc92d9d23c5b5faee741", size = 554840, upload-time = "2025-10-19T02:30:45.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/9c/79827f246965ed66ae8d2f3e3937e552730eaf48b270dac852a4756c7bf4/esperanto-2.7.0-py3-none-any.whl", hash = "sha256:2ea3fa98d8622d08a18dc6701ad362461de02492a3252326c70c969b3aba3db6", size = 129524, upload-time = "2025-10-19T02:04:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/ee/91/a2c43a4eb3c56f349d59c656783cdd8cd13149a8e521e75f77e84dcd5ac0/esperanto-2.7.1-py3-none-any.whl", hash = "sha256:ea81e531085e32828b2ecd02680088801222dd21e7a73012efdb9b68fa627ed4", size = 129593, upload-time = "2025-10-19T02:30:44.149Z" }, ] [[package]] @@ -2199,7 +2199,7 @@ wheels = [ [[package]] name = "open-notebook" -version = "1.0.1" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "ai-prompter" },