OmniRoute¶
Self-hosted OmniRoute AI gateway: an OpenAI-compatible API endpoint (/v1, with /api/v1 serving the same handlers) in front of many upstream providers, plus a management dashboard (served under /dashboard) behind oauth2-proxy.
Disabled by default. Set omniroute_enable = true (or TF_VAR_omniroute_enable) to deploy. Independent of the litellm module; both can run at once.
What this module creates¶
| Resource | Name | Notes |
|---|---|---|
| Namespace | omniroute |
|
| Secret | omniroute-auth |
JWT_SECRET, API_KEY_SECRET, INITIAL_PASSWORD, STORAGE_ENCRYPTION_KEY, mounted with envFrom |
| Helm release | omniroute |
Chart omniroute, from https://chrisleekr.github.io/helm-charts |
| ListenerSet | slug of omniroute_domain |
Contributes this host's HTTPS listener to the shared Istio Gateway |
| Certificate | omniroute-tls |
Issued by letsencrypt-gateway over DNS-01 |
| HTTPRoute | slug of omniroute_domain |
Every path to omniroute:20128, carrying the shared request and response header filters |
| AuthorizationPolicy | <slug>-require-auth |
Created in the Gateway's namespace. CUSTOM action delegating to the oauth2-proxy ext_authz provider |
The chart owns the Service (omniroute:20128), the PVC (omniroute-data), the ServiceAccount (automountServiceAccountToken: false), and a single-replica Deployment with a Recreate update mode. None of those are overridden here.
Design notes¶
All 4 auth keys in a module-owned Secret. The chart can generate JWT_SECRET / API_KEY_SECRET itself, but only during a live helm install/upgrade via a cluster lookup. Any client-side render (helm template, ArgoCD/Flux without server-side apply) cannot see the existing Secret and mints NEW values every sync, which makes stored provider keys unreadable. The module therefore sets auth.existingSecret: omniroute-auth and supplies all four keys from Terraform, so the chart skips its own secret.yaml entirely and no credential is written into the Helm values string.
One route, a path-scoped gate. OmniRoute serves its dashboard and all non-API routes from the host root with no gate of its own, so without oauth2-proxy the console HTML would be public. The API prefixes must stay open, because OmniRoute enforces REQUIRE_API_KEY itself and an HTTP redirect to an SSO login page would break SDK clients.
Path selection and auth selection are separate concerns here: one HTTPRoute carries every path to the same Service, and the AuthorizationPolicy decides per path whether the ext_authz gate runs. Istio ORs its rules, so the gate fires when the path is an admin route, or when it is not part of the open API surface.
SSE needs no extra configuration. Envoy speaks HTTP/1.1 upstream by default and does not buffer responses.
/v1 and /api/v1 are both open because they are the same handler. /v1 is the canonical one the dashboard emits, so opening only /api/v1 leaves the dashboard handing out URLs that redirect to the login page.
Because the API prefixes are unauthenticated at the edge, the module forces extraConfig.REQUIRE_API_KEY: "true" so OmniRoute requires an API key on every proxy call. REQUIRE_API_KEY is resolved with a database override ahead of the environment value, so never disable it from the dashboard while these prefixes are served open, or they become anonymous.
Admin suffixes gated as defense in depth. The open prefixes also carry management routes (/management, /agents, /accounts, /registered-keys) that OpenAI-compatible clients never call. var.omniroute_gated_admin_suffixes pulls them back into the gated set, so they need an oauth2 session on top of the API key while model calls stay open. Dashboard browser calls still pass, carrying the cookie. Set the variable to [] to disable.
Every alias must be gated, not just the ones the open route serves. The gated set is locals.tf's alias prefixes crossed with these suffixes, because an alias not covered by the gated set falls through to the open prefix and reaches the handler ungated. Both lists are app-version dependent; re-verify against a running container on every image bump.
Istio prefixes are not element-wise. Istio matches a trailing * as a raw string prefix, so notPaths: ["/v1*"] would also exempt /v1beta and silently un-gate it. httproute.tf therefore emits two entries per path, the bare path and the /* form, so /v1 covers /v1/models but never /v1beta.
Residual public surface. Only the paths in omniroute_public_paths are unauthenticated. Anything else falls through to the gate. If you enable web-cookie providers (gemini-web, claude-web) or external OAuth/webhook callbacks, those callback paths must be added to omniroute_public_paths or the provider handshake fails behind the login redirect:
| Candidate path | When to add it |
|---|---|
/api/v1, /v1 |
Always (both are defaults). The OpenAI-compatible API base paths, same handlers |
/api/oauth, /api/webhooks |
Provider OAuth or webhook callbacks that must reach the app unauthenticated |
/.well-known |
Provider discovery documents. Not needed for certificates: this host issues over DNS-01 |
The exact set is app-version dependent; re-verify it against a running container before widening it.
Single-writer store. OmniRoute persists everything in SQLite on the PVC. The chart hard-pins one replica and a Recreate update mode, and the module leaves both alone: two pods on one PVC corrupt the database. There is no HPA for the same reason.
Variables¶
| Variable | Type | Default | Purpose |
|---|---|---|---|
omniroute_enable |
bool | false |
Module gate. Everything below is inert while false |
omniroute_domain |
string | omniroute.chrislee.local |
Single host serving the API and the dashboard |
omniroute_public_paths |
list(string) | ["/api/v1", "/v1"] |
Paths served by the open route. Omissions fall through to the gated route |
omniroute_gated_admin_suffixes |
list(string) | ["/management", "/agents", "/accounts", "/registered-keys"] |
Admin suffixes pulled back behind oauth2-proxy, applied to every API alias prefix (/api/v1, /v1, /v1/v1). [] disables |
omniroute_chart_version |
string | 0.3.1 |
omniroute chart pin. Bump with the image tag |
omniroute_image_tag |
string | "" |
diegosouzapw/omniroute tag. Empty uses the chart appVersion; use -web for web-cookie providers |
omniroute_storage_class_name |
string | longhorn |
SQLite PVC storage class |
omniroute_storage_size |
string | 5Gi |
SQLite PVC size |
omniroute_initial_password |
string, sensitive | "" |
First-boot dashboard password, min 12 chars |
omniroute_jwt_secret |
string, sensitive | "" |
Session-signing key, min 32 chars. Rotatable |
omniroute_api_key_secret |
string, sensitive | "" |
Encrypts stored provider keys, min 32 chars. Write once |
omniroute_storage_encryption_key |
string, sensitive | "" |
Encrypts the DB at rest, min 32 chars. Write once |
istio_gateway_name |
string | public |
Shared Gateway this module's ListenerSet attaches to |
istio_gateway_namespace |
string | istio-ingress |
Where the AuthorizationPolicy is created, beside the Gateway its targetRefs names |
The four credential variables default to "" so the module stays valid while disabled. Their validation blocks reject a malformed non-empty value; precondition blocks in secrets.tf make them mandatory once omniroute_enable = true.
Secrets¶
Supplied through Bitwarden Secrets Manager as TF_VAR_*; see Bitwarden secrets setup.
| Variable | How to generate |
|---|---|
omniroute_initial_password |
openssl rand -base64 24 (a strong password) |
omniroute_jwt_secret |
openssl rand -hex 32 |
omniroute_api_key_secret |
openssl rand -hex 32 |
omniroute_storage_encryption_key |
openssl rand -hex 32 |
omniroute_api_key_secretencrypts every provider credential andomniroute_storage_encryption_keyencrypts the database at rest. Write both once and never rotate them. Rotating either makes already-stored data permanently unreadable.omniroute_jwt_secretonly signs sessions and may be rotated (it logs everyone out).
Operations¶
kubectl -n omniroute get pods,pvc,httproute,secret
# /api/monitoring/health is the in-pod liveness/readiness probe. It is NOT in
# omniroute_public_paths, so from the edge it falls through to the oauth2 gate and returns a login
# redirect. Reach it directly instead:
kubectl -n omniroute port-forward svc/omniroute 20128:20128
curl -sS http://127.0.0.1:20128/api/monitoring/health
# The open API surface, reachable from outside with an API key:
curl -sS https://$DOMAIN/v1/models -H "Authorization: Bearer $API_KEY"
# Admin suffixes must stay gated on every alias. 302 is the oauth2-proxy login redirect, so only it
# proves the gate held. Transport, TLS and gateway failures also return non-302 without reaching the
# app, so they are an inconclusive check rather than a bypass. Re-run after every image bump.
for p in /v1/management /api/v1/management /v1/v1/management; do
code=$(curl -so /dev/null -w '%{http_code}' "https://$DOMAIN$p")
case "$code" in
302) echo "OK: $p gated" ;;
000|5??) echo "INCONCLUSIVE: $p returned $code, request never reached the app" ;;
*) echo "GATE BYPASS: $p returned $code" ;;
esac
done
Caveats¶
- The PVC holds the only copy of state. Providers, keys, and settings live in SQLite on the
omniroute-dataPVC. Settingomniroute_enable = falsedeletes the Namespace, and that is what reaps the PVC and the data. Back up before disabling. - Write-once keys. Rotating
omniroute_api_key_secretoromniroute_storage_encryption_keyafter providers have been added makes every stored credential permanently unreadable. Generate them once and keep them in Bitwarden. - TLS on a
.localdomain never issues.letsencrypt-gatewaysolves DNS-01 through Cloudflare, which holds no zone foromniroute.chrislee.local. Certificates only issue once a real public domain is set. This is the repo-wide pattern, not specific to this module. -webimage flavor. Web-cookie providers (gemini-web,claude-web,claude-turnstile) need the-webimage; setomniroute_image_tagto e.g.3.8.50-web, and add their callback paths toomniroute_public_paths.- Chart and image versions move together. Bump
omniroute_chart_versionandomniroute_image_tagas a pair. - Heap cap and memory limit move together.
OMNIROUTE_MEMORY_MBis the V8 old-space ceiling andresources.limits.memorymust clear it by several hundred MiB, both intemplates/omniroute-values.tftpl. Undersizing the cap aborts the process with "Reached heap limit" and reports exit 0, so it looks like a clean shutdown.OmniRouteMemoryNearHeapCeilingin monitoring is tuned to the gap between the two values, so retune it when either changes. - Credential health is reactive, not scheduled.
extraConfig.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK: "true"intemplates/omniroute-values.tftplstops the 5-minute sweep that re-probed every provider connection, because each probe billed a real upstream call against free-tier quota. A live request that fails auth still flips its connection on its own, but nothing re-probes on a schedule any more, so a credential that goes bad while idle keeps reading healthy until something uses it. Use Test Connection on a connection to refresh it on demand. - Restarts are full downtime. One replica on a single-writer PVC with a
Recreatestrategy, so every restart or apply drops the gateway for roughly 30s.OmniRouteContainerRestartedandOmniRouteDownpage on this; stockKubePodCrashLoopingdoes not, because the pod recovers without enteringCrashLoopBackOff.