LiteLLM¶
Self-hosted LiteLLM proxy: one OpenAI-compatible /v1 endpoint in front of many upstream providers, plus an admin console at /ui.
Disabled by default. Set litellm_enable = true (or TF_VAR_litellm_enable) to deploy.
What this module creates¶
| Resource | Name | Notes |
|---|---|---|
| Namespace | litellm |
|
| Secret | litellm-masterkey |
Chart injects it as env PROXY_MASTER_KEY |
| Secret | litellm-env |
LITELLM_SALT_KEY + provider API keys, mounted with envFrom |
| Secret | litellm-db |
Postgres username / password |
| StatefulSet + Service | litellm-postgres |
Upstream postgres:*-alpine on a Longhorn PVC |
| Helm release | litellm |
Chart litellm-helm, from oci://ghcr.io/berriai |
| Ingress | litellm-api |
Path /, SSE-safe, TLS + cert-manager annotation |
| Ingress | litellm-ui |
var.litellm_ui_paths, behind oauth2-proxy |
Design notes¶
Module-owned Postgres, not the chart's. The chart's db.deployStandalone path generates its own <release>-dbcredentials Secret from plaintext Helm values and ignores existingSecret, which would put the database password into the Helm release secret and into Terraform state. This module uses db.useExisting and points the chart at litellm-db, so both the Deployment and the Prisma migrations Job read the credentials via secretKeyRef. Bitnami's postgresql image is also 404 on Docker Hub since the August 2025 catalog migration.
Split ingress. The chart's single Ingress serves /v1 and /ui together. The console is reachable with no credentials while the API enforces the virtual/master key, so the console HTML is effectively public and only the API is protected. The chart Ingress is therefore disabled and the module writes two: an oauth2-proxy-gated one for the console paths, and a clean one for everything else.
litellm_ui_paths has to cover every path the console actually uses, because whatever it omits falls through to the unauthenticated API Ingress. Beyond /ui and /sso that means /litellm-asset-prefix (the console loads its JS and CSS from there, not from /ui), /fallback/login (the login form itself, served unauthenticated) and /login (the credential POST target). These paths track the upstream console, so recheck them when bumping the chart.
Both Ingresses share one host and one TLS secret, and only litellm-api carries cert-manager.io/cluster-issuer. Annotating both would create competing Certificates for the same secret.
Residual public surface. LiteLLM serves a fixed allow-list of unauthenticated routes under /. NO_DOCS: "True" only removes the /docs UI. /openapi.json still returns the full schema and /redoc still renders it. So the introspection routes (/docs, /redoc, /openapi.json, /routes, /config/yaml, /public) are gated by litellm_ui_paths instead, and NO_DOCS is defence in depth. /config/yaml returns the proxy config and /public/* the model, agent, mcp and skill hubs, so both disclose which providers hold keys here; no OpenAI-SDK client requests either.
Still public by design, and deliberately not gated:
| Route | Why it stays open |
|---|---|
/ |
Also the API catch-all prefix; gating it would break every client |
/health/liveliness, /health/liveness |
Probes and external monitoring. /health/readiness is not on the upstream allow-list and already requires auth |
/.well-known/* |
cert-manager's HTTP-01 solver answers under /.well-known/acme-challenge |
/test |
Upstream public-route allow-list; returns a static response, discloses nothing |
No model_list in git. store_model_in_db: true lets an admin add models live in /ui, persisted in Postgres. The values template sets model_list: null explicitly. Omitting the key would let the chart's own example model coalesce into the rendered ConfigMap. To promote a model into git, replace the null with a list and keep API keys as os.environ/<KEY> references.
Adding a provider needs no Terraform change: add the key to the TF_VAR_litellm_provider_secrets JSON object and reference it from proxy_config as os.environ/<KEY>.
Variables¶
| Variable | Type | Default | Purpose |
|---|---|---|---|
litellm_enable |
bool | false |
Module gate. Everything below is inert while false |
litellm_domain |
string | litellm.chrislee.local |
Single host serving both /v1 and /ui |
litellm_ingress_class_name |
string | nginx |
Ingress class for both Ingresses |
litellm_ingress_enable_tls |
bool | true |
Wired from the root ingress_enable_tls |
litellm_ui_paths |
list(string) | ["/ui", "/sso", "/litellm-asset-prefix", "/fallback/login", "/login", "/docs", "/redoc", "/openapi.json", "/routes", "/config/yaml", "/public"] |
Paths routed behind oauth2-proxy. Omissions fall through unauthenticated |
litellm_chart_version |
string | 1.89.2 |
litellm-helm chart pin |
litellm_image_tag |
string | 1.89.2 |
ghcr.io/berriai/litellm-database pin. Bump with the chart |
litellm_postgres_image_tag |
string | 18.4-alpine |
Must end in -alpine: the pod sets fs_group = 70 |
litellm_replicas |
number | 1 |
Proxy replicas, minimum 1 |
litellm_storage_class_name |
string | longhorn |
Postgres volume storage class |
litellm_storage_size |
string | 10Gi |
Postgres volume size. See the resize caveat below |
litellm_master_key |
string, sensitive | "" |
Admin/API superuser key, must start with sk- |
litellm_salt_key |
string, sensitive | "" |
Encrypts DB-stored credentials, must start with sk-. Write once |
litellm_db_password |
string, sensitive | "" |
Postgres password, minimum 16 characters, restricted to A-Z a-z 0-9 _ . ~ - because it is interpolated into a postgresql:// URI |
litellm_provider_secrets |
map(string), sensitive | {} |
Provider API keys exported to the pod as env vars |
auth_oauth2_proxy_host |
string | "" |
oauth2-proxy host guarding the console |
The three string credentials (litellm_master_key, litellm_salt_key, litellm_db_password) 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 litellm_enable = true. litellm_provider_secrets carries neither layer and stays optional: a proxy with no providers configured yet is a valid state.
Secrets¶
Supplied through Bitwarden Secrets Manager as TF_VAR_*; see Bitwarden secrets setup.
| Variable | How to generate |
|---|---|
litellm_master_key |
echo "sk-$(openssl rand -hex 24)" |
litellm_salt_key |
echo "sk-$(openssl rand -hex 24)" |
litellm_db_password |
openssl rand -hex 16 |
litellm_provider_secrets |
JSON object of provider keys |
litellm_salt_keyencrypts every provider credential stored in the database. Write it once and never rotate it. Rotating makes all stored credentials permanently unreadable.
Operations¶
kubectl -n litellm get pods,sts,pvc,ingress
kubectl -n litellm logs job/litellm-migrations # "All migrations have been successfully applied."
# Job self-deletes 1h after finishing
# (migrationJob.ttlSecondsAfterFinished)
curl -sS https://$DOMAIN/health/liveliness # 200, no auth
curl -sS https://$DOMAIN/v1/models -H "Authorization: Bearer $MASTER_KEY"
/v1/models returns an empty list until models are added through /ui.
Caveats¶
- The database holds the only copy of model configuration. With
model_listempty in git, models added through/uiexist only in Postgres. Settinglitellm_enable = falsedeletes the Namespace, and that is what reaps the PVC. Deleting a StatefulSet on its own leaves PVCs behind, since the default volume-claim retention policy isRetain. Promote anything important intoproxy_config. helm rollbackdoes not re-run migrations. The migration Job is apre-install,pre-upgradehook, and a rollback firespre-rollbackinstead, so the old image would meet the new schema. Prisma migrations are forward-only: recover by rollinglitellm_chart_versionandlitellm_image_tagforward, never back. Terraform never rolls back on its own, so this only applies to a manualhelm rollback.- Growing
litellm_storage_sizedoes not resize the volume. Changing it replaces the StatefulSet, but the existing PVC survives replacement and the controller only creates PVCs that are missing, so the new size is never applied. Expand the volume directly instead, then update the variable to match so the two do not diverge:
kubectl -n litellm patch pvc data-litellm-postgres-0 \
-p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'
Longhorn supports online expansion, so no downtime is needed. Shrinking is not possible.
- Rotating litellm_db_password breaks the deployment. The postgres image only reads POSTGRES_PASSWORD when the data directory is empty, so a new value updates the Secret and the clients but not the database role, and every connection starts failing authentication. Change the role first, then the variable:
kubectl -n litellm exec -it litellm-postgres-0 -- \
psql -U litellm -d litellm -c "ALTER USER litellm WITH PASSWORD '<new>';"
- TLS on a
.localdomain never issues.letsencrypt-prodcannot complete an HTTP-01 challenge forlitellm.chrislee.local. Certificates only issue once a real public domain is set. This is the repo-wide pattern, not specific to this module. - Chart and image versions move together. Bump
litellm_chart_versionandlitellm_image_tagas a pair.