Reverse Proxy Setup¶
A reverse proxy terminates TLS and routes traffic to the ai.doo services. Caddy is recommended for its automatic HTTPS, but an nginx configuration is also provided.
Quick start
If you used the installer with --with-caddy, the Caddyfile and compose overlay are already generated. You only need to ensure DNS records and firewall rules are in place.
Service Ports¶
| Service | Internal Port | Container Name | Suggested Public Path |
|---|---|---|---|
| Hub | 8000 | hub |
hub.example.com |
| PIKA | 8000 | pika / pika-app |
pika.example.com |
| VERA frontend | 3000 | vera-frontend |
vera.example.com |
| VERA backend | 8000 | vera-backend |
vera.example.com/api/* |
Danger
Never expose Ollama (port 11434) to the public internet. It has no authentication. Only the Docker bridge network (ollama_network) should be able to reach it. See the firewall guide for details.
Prerequisites¶
- DNS A records — point
hub.example.com,pika.example.com, andvera.example.comto your server's public IP - Ports 80 and 443 open — Caddy needs port 80 for the ACME HTTP-01 challenge and HTTP→HTTPS redirect
- Docker network — the proxy must be on the
ollama_networkbridge to reach the services
Caddy (Recommended)¶
Caddy obtains and renews TLS certificates automatically via Let's Encrypt.
Caddyfile¶
{
email admin@example.com
servers {
protocols h1 h2 h3
}
}
# Shared security headers
(security_headers) {
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
-Server
}
}
hub.example.com {
import security_headers
header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'"
reverse_proxy hub:8000 {
flush_interval -1 # required for SSE model-pull streaming
header_up X-Forwarded-Proto {scheme}
}
}
pika.example.com {
import security_headers
header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"
reverse_proxy pika:8000 {
flush_interval -1 # required for SSE query streaming
header_up X-Forwarded-Proto {scheme}
}
request_body {
max_size 100MB # document uploads
}
}
vera.example.com {
import security_headers
header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://vera.example.com"
handle /api/* {
reverse_proxy vera-backend:8000 {
flush_interval -1 # required for SSE status streaming
header_up X-Forwarded-Proto {scheme}
}
}
handle /internal/* {
respond "Not Found" 404 # block internal endpoints
}
handle {
reverse_proxy vera-frontend:3000
}
request_body {
max_size 25MB # matches VERA's MAX_UPLOAD_MB default
}
}
Docker Compose Overlay¶
Create a docker-compose.caddy.yml alongside your main compose files:
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- ollama_network
networks:
ollama_network:
external: true
name: ollama_network
volumes:
caddy_data:
caddy_config:
Start it with:
Note
Caddy must be on the same Docker network as the services it proxies. The ollama_network bridge is shared by all ai.doo services.
Using Your Existing Reverse Proxy¶
If you already run nginx, Traefik, HAProxy, or a cloud load balancer, you don't need Caddy. Configure your proxy with these requirements:
X-Forwarded Headers¶
All ai.doo services check X-Forwarded-Proto to determine whether the original request was HTTPS. This is critical because:
- Secure cookies — Hub, PIKA, and VERA set
secure=trueon session cookies. IfX-Forwarded-Proto: httpsis missing, the cookie won't be sent on subsequent requests and users can't log in - CSRF validation — VERA's CSRF middleware checks the origin against the forwarded scheme
Your proxy must set:
SSE Streaming¶
PIKA, VERA, and Hub use Server-Sent Events (SSE) for real-time updates:
| Service | SSE Endpoint | Purpose |
|---|---|---|
| Hub | GET /api/models/pull/stream |
Model download progress |
| PIKA | GET /api/v1/query/stream |
Streaming query responses |
| VERA | GET /documents/{id}/status/stream |
Document processing status |
SSE requires:
- No response buffering — the proxy must flush each chunk immediately. In nginx:
proxy_buffering off;. In Caddy:flush_interval -1 - Long timeouts — SSE connections stay open. Set
proxy_read_timeout 300sor higher in nginx - No gzip on
text/event-stream— some proxies compress SSE responses, breaking the streaming protocol. Excludetext/event-streamfrom compression
Upload Body Size¶
| Service | Default Max Upload | Config Variable |
|---|---|---|
| PIKA | 100 MB | MAX_UPLOAD_SIZE |
| VERA | 25 MB | MAX_UPLOAD_MB |
Your proxy's client_max_body_size (nginx) or request_body max_size (Caddy) must match or exceed these values, or uploads will fail with a 413 Request Entity Too Large before reaching the application.
CORS¶
VERA's frontend (vera.example.com) makes API calls to the backend, which is routed through the same hostname at /api/*. If your proxy separates them onto different origins, you must configure VERA's CORS_ORIGINS environment variable to include the frontend's origin.
Health Check Endpoints¶
For load balancer health probes:
| Service | Health Endpoint |
|---|---|
| Hub | GET /health |
| PIKA | GET /health (lightweight) or GET /api/v1/health (detailed) |
| VERA | GET /health |
nginx¶
If you prefer nginx, here is an equivalent configuration:
upstream hub {
server hub:8000;
}
upstream pika {
server pika:8000;
}
upstream vera_api {
server vera-backend:8000;
}
upstream vera_frontend {
server vera-frontend:3000;
}
server {
listen 443 ssl http2;
server_name hub.example.com;
ssl_certificate /etc/letsencrypt/live/hub.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hub.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location / {
proxy_pass http://hub;
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;
# SSE streaming support
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
}
server {
listen 443 ssl http2;
server_name pika.example.com;
ssl_certificate /etc/letsencrypt/live/pika.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pika.example.com/privkey.pem;
client_max_body_size 100M;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location / {
proxy_pass http://pika;
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;
# SSE streaming support
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
}
server {
listen 443 ssl http2;
server_name vera.example.com;
ssl_certificate /etc/letsencrypt/live/vera.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vera.example.com/privkey.pem;
client_max_body_size 25M;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location /api/ {
proxy_pass http://vera_api;
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;
# SSE streaming support
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
# Block internal endpoints
location /internal/ {
return 404;
}
location / {
proxy_pass http://vera_frontend;
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;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name hub.example.com pika.example.com vera.example.com;
return 301 https://$host$request_uri;
}
Tip
With nginx you must manage TLS certificates yourself. Consider certbot for automated Let's Encrypt renewals.
Security Headers¶
Both configurations above include these recommended headers:
| Header | Value | Purpose |
|---|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Enforce HTTPS for 2 years |
X-Content-Type-Options |
nosniff |
Prevent MIME-type sniffing |
X-Frame-Options |
DENY / SAMEORIGIN |
Prevent clickjacking |
Content-Security-Policy |
App-specific | Restrict script/style sources |
Referrer-Policy |
strict-origin-when-cross-origin |
Limit referrer information |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Disable unnecessary browser APIs |
Note
VERA uses SAMEORIGIN for X-Frame-Options and a more permissive CSP because the Next.js frontend requires unsafe-eval for the standalone build.
Troubleshooting¶
"Can't log in" / session cookie not persisting¶
Your proxy is not sending X-Forwarded-Proto: https. The application sets secure=true on cookies, so the browser will only send them over HTTPS. Verify the header reaches the backend:
SSE streaming not working / responses arrive all at once¶
Your proxy is buffering the response. Disable buffering for SSE endpoints (see the SSE section above).
"413 Request Entity Too Large" on uploads¶
Your proxy's body size limit is smaller than the file being uploaded. Increase client_max_body_size (nginx) or request_body max_size (Caddy).
VERA API calls fail with CORS errors¶
The frontend origin doesn't match the CORS allowlist. Set CORS_ORIGINS in VERA's environment to include the frontend URL (e.g., https://vera.example.com).