FreeInference Deployment
Cloudflare + console + FastAPI (current)
Public traffic reaches the console first, and the console routes it:
Client ──▶ Cloudflare Tunnel ──▶ Console (:3001) ──┬──▶ FastAPI (:8080)
├──▶ pgAdmin (:5050) [admin-only]
└──▶ its own pages
Layer |
Role |
|---|---|
Cloudflare |
CDN, DDoS protection, edge SSL termination. A Cloudflare Tunnel ( |
Console |
Path routing, via the rewrite table in |
FastAPI |
API logic — request authentication, model routing, backpressure, Qdrant proxy, and observability. Listens on |
Console path routing (apps/frontend/next.config.js):
/v1/,/anthropic/,/auth/,/user/,/admin/,/internal/playground/,/internal/verify-admin,/internal/verify-grafana,/health,/site-updates,/site-config→ FastAPI/pgadmin/→ pgAdmin, gated on an admin session byapps/frontend/src/app/pgadmin/[[...path]]/route.ts— a rewrite cannot authenticate, so this one path is a route handler rather than a table entryeverything else → the console’s own pages
Nginx is installed on the host but is not in the public path. It still holds a config with its own copy of the routing above, and that config still works when you reach it directly on the host — but the tunnel delivers public traffic straight to the services, so none of it runs. Verified 2026-08-05:
/internal/verify-adminanswers 404 from Nginx (the location is markedinternal) and 401 from FastAPI over the public URL. Do not add a public route by editing Nginx; it will do nothing.
Docker Compose manages all services (backend, frontend, PostgreSQL, plus
pgAdmin behind the admin profile) with automatic restarts via
restart: unless-stopped.
Deployment
All services are defined in deploy/docker/docker-compose.yml. From the project root:
cp .env.example .env # Configure secrets
make up # Start all services
make ps # Verify health
Nginx runs on the host (not containerized), left over from the pre-tunnel topology described above. See Deployment for the full guide.
Client IP resolution
apps/backend/serving/utils/request_ip.py resolves the client IP, gated by two
independent flags:
Flag |
Asserts |
Default |
|---|---|---|
|
Some trusted proxy rewrites |
|
|
The immediate proxy is Cloudflare, so |
|
With both set, resolution order is CF-Connecting-IPv6 → CF-Connecting-IP →
X-Forwarded-For (first routable entry — leading private/loopback/ULA hops an
intermediary inserted are skipped) → X-Real-IP → socket peer.
CF-Connecting-IPv6 outranks CF-Connecting-IP, but only when the two
corroborate each other. Cloudflare sends the IPv6 header solely when
Pseudo IPv4 is set to
“Overwrite headers” — in which case CF-Connecting-IP holds a synthetic Class E
(240.0.0.0/4) address derived from the visitor rather than the visitor’s real
address. Preferring the synthetic would send an IPv6 client down the IPv4
bucketing path, handing every rotated privacy address its own rate-limit bucket
and defeating the /64 grouping below.
The corroboration matters because Cloudflare omits CF-Connecting-IPv6 when
Pseudo IPv4 is off rather than clearing it — so any caller can supply one.
Only CF-Connecting-IP is overwritten on every request. The gateway therefore
honors the IPv6 header only when it parses as IPv6 and CF-Connecting-IP
holds the accompanying Class E synthetic; a real client address is never drawn
from that reserved range, so the pairing cannot be forged from outside.
Otherwise CF-Connecting-IP remains authoritative. Both headers are logged, so
a synthetic — or a forgery attempt — stays visible.
Before setting
TRUST_CLOUDFLARE_HEADERS=1, restrict the origin. Every one of these headers is attacker-controlled on any request that reaches the origin without passing through the edge, and Cloudflare’s “Full (strict)” TLS mode does not prevent that — it authenticates the origin to Cloudflare, not Cloudflare to the origin. Until Nginx enforces Authenticated Origin Pulls or a Cloudflare IP allowlist, anyone who learns the origin address can send a forgedCF-Connecting-IPdirectly. This caveat is not new to the Cloudflare header — it applies equally toTRUST_PROXY_HEADERSandX-Forwarded-For— but neither flag should be enabled on the assumption alone.
CF-Connecting-IP comes first deliberately. Cloudflare always overwrites that
header, but it appends to a client-supplied X-Forwarded-For — so reading
the leftmost X-Forwarded-For entry would let any caller dictate the IP the
gateway logs and rate-limits on, unless the host Nginx config also rewrites the
header.
The two flags are separate because they are separate facts. A non-Cloudflare
proxy may rewrite X-Forwarded-For perfectly well while forwarding a
client-supplied CF-Connecting-IP untouched — trusting the Cloudflare header on
the strength of generic proxy trust would hand that caller the spoof it was
denied via X-Forwarded-For. If you front this service with anything other
than Cloudflare, leave TRUST_CLOUDFLARE_HEADERS=0.
Each request log line carries remote_ip (the resolved client), peer_ip (the
socket peer — Nginx on loopback in this topology), ip_source, and the raw
header values, so the provenance of any address is visible after the fact.
IPv6 addresses in the logs are expected even though the origin is
IPv4-only. Cloudflare publishes an AAAA record regardless of origin support:
a client connects to the edge over IPv6, and Cloudflare opens a separate IPv4
connection to the origin carrying the original address in CF-Connecting-IP.
The client’s address family is decoupled from ours. peer_ip should always be
IPv4 here — an IPv6 peer_ip would be a genuine surprise.
Because a client typically holds an entire IPv6 prefix (a /64 at minimum) and
RFC 4941 privacy addresses rotate within it, full IPv6 addresses make poor
identity keys. Logs and analytics keep the full address, but rate-limit and
routing-affinity buckets fold IPv6 to its /64 via normalize_ip_bucket().
IPv4 continues to bucket per address, and IPv4-mapped literals
(::ffff:192.0.2.1, which a dual-stack listener reports for IPv4 peers) bucket
on the embedded IPv4 rather than being folded by prefix.
Runtime Operations
Restart:
make restartormake restart s=backendFollow logs:
make logsormake logs s=backendHealth check:
curl https://freeinference.org/healthList registered models:
curl https://freeinference.org/v1/models | jq
Why Nginx Is Back
Nginx was briefly removed (see Legacy section below) when FreeInference was API-only and Cloudflare handled all edge concerns. It was re-introduced when we added:
Frontend: The Next.js web UI runs on port 3001 and needs to share the
freeinference.orgdomain with the API. Path-based routing (/v1/*→ backend,/*→ frontend) is a natural fit for Nginx.Body size limits: Qdrant vector upserts can be large. Nginx’s
client_max_body_sizegives a clear, configurable gate before traffic hits FastAPI.WebSocket upgrade: Nginx handles the
Upgrade/Connectionheaders cleanly for SSE and WebSocket-based streaming.
Legacy Architectures
FastAPI direct (v3, abandoned)
We previously served OpenRouter-compatible traffic directly through FastAPI listening on port 80, without Nginx. This was simpler but could not support frontend co-hosting or fine-grained body size limits. Once the frontend was added, we moved back to Nginx.
Nginx (v2, abandoned)
We briefly fronted FastAPI (running on port 8080) with vanilla Nginx that listened on port 80 (redirecting to HTTPS) and terminated TLS on port 443 for https://freeinference.org. Once Cloudflare took over edge SSL duties, the extra hop mostly added deployment and observability complexity without material benefit, so the setup was removed.
Nginx + Lua via OpenResty (v1, abandoned)
We previously relied on OpenResty (Nginx + Lua) to provide a production routing tier across multiple LLM backends. The stack handled model mapping, load balancing, health checks, and error handling. We keep the installation notes for posterity.
Overview
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Client │─────▶│ OpenResty │─────▶│ Backend 1 │
│ (API Call) │ │ (Router) │ │ (Qwen@8000) │
└─────────────┘ │ │ └─────────────────┘
│ - Model Mapping │
│ - Load Balancing│ ┌─────────────────┐
│ - Health Checks │─────▶│ Backend 2 │
│ - Error Handling│ │ (Test@8001) │
└──────────────────┘ └─────────────────┘
Installation Notes
# Add repository
wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add -
echo "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main" | \
sudo tee /etc/apt/sources.list.d/openresty.list
# Install
sudo apt-get update
sudo apt-get install openresty
# Create directory
sudo mkdir -p /usr/local/openresty/nginx/conf/sites-available
sudo mkdir -p /usr/local/openresty/nginx/conf/sites-enabled
# Copy Config file
sudo cp <your config file> /usr/local/openresty/nginx/conf/sites-available/vllm
# Enable the site
sudo ln -s /usr/local/openresty/nginx/conf/sites-available/vllm \
/usr/local/openresty/nginx/conf/sites-enabled/vllm
http {
# ... Others ...
# Lua settings
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
lua_shared_dict model_cache 10m;
# Include Site Configuration
include /usr/local/openresty/nginx/conf/sites-enabled/*;
}
# test openresty config
sudo openresty -t
# Start
sudo systemctl start openresty
# Enable auto-start
sudo systemctl enable openresty
# reload openresty
sudo openresty -s reload
The model paths below are historical and may no longer match the registry; query /v1/models for the currently registered models.
# Chat with Qwen3-Coder
curl -X POST http://freeinference.org/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "/models/Qwen_Qwen3-Coder-480B-A35B-Instruct-FP8", "messages": [{"role": "user", "content": "Hello"}], "max_tokens": 50}'
# Chat with llama4-scout
curl -X POST http://freeinference.org/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "/models/meta-llama_Llama-4-Scout-17B-16E", "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 50}'
Nginx (v0, abandoned)
sudo vim /etc/nginx/sites-available/vllm
sudo nginx -t
sudo systemctl reload nginx
# to test the endpoint
curl https://freeinference.org/v1/models