LiteLLM Helm Chart
Deploys the LiteLLM proxy: an OpenAI-compatible gateway in front of every model provider, plus the management API and the Admin UI. One Deployment serves all three.
This chart is a clean-room replacement for the two upstream charts (helm/litellm-helm and helm/litellm). It carries neither as a dependency.
The reason it exists is routing. Both upstream charts publish through a networking.k8s.io/v1 Ingress and nothing else, so a cluster running Envoy Gateway, Contour, or OpenShift has to bolt its own object on afterwards, usually as a hand-written manifest outside the release. Here each implementation is a first-class, schema-validated template.
| Implementation | API | Typical controller |
|---|---|---|
| Ingress | networking.k8s.io/v1 |
ingress-nginx, HAProxy, Traefik |
| HTTPRoute | gateway.networking.k8s.io/v1 |
Envoy Gateway, Contour, Istio, NGINX Gateway Fabric |
| HTTPProxy | projectcontour.io/v1 |
Contour |
| Route | route.openshift.io/v1 |
OpenShift |
More than one may be enabled at a time, which is how you move from Ingress to Gateway API without a gap in service.
Contents
- Prerequisites
- Install
- Versions
- Model management
- Provider credentials
- Routing
- Sharing a hostname
- Single sign-on
- Database
- Creating the database
- Redis
- Response cache
- Migration Job
- Secrets
- Scaling and availability
- Observability
- Tracing with Langfuse
- Running read-only
- Values reference
- Validation
- Upgrading
- Troubleshooting
- Development
- Not included
Prerequisites
- Kubernetes 1.25 or later
- Helm 3.19 or later
- The CRDs for whichever routing implementation you enable
- PostgreSQL 14 or later, for the database flow only
- Redis 7.0 or later, for more than one replica — or
valkey.enabled, which deploys one - The Altinity ClickHouse operator and cert-manager, for
langfuse.deploy.enabledonly
This chart deploys no database. Both upstream charts bundle the Bitnami subcharts, whose versioned tags were withdrawn from docker.io/bitnami and now need a bitnamilegacy repoint to install at all. A proxy that stores virtual keys and spend data belongs in front of a database you back up and patch, so this chart points at one you already run instead.
Two optional subcharts exist, both off by default, and neither is Bitnami:
| Dependency | Chart | Switched on by | What it is for |
|---|---|---|---|
| Valkey | valkey-io/valkey-helm |
valkey.enabled |
The response cache and the coordination store |
| Langfuse | langfuse/langfuse-k8s |
langfuse.deploy.enabled |
Tracing, where nothing else runs Langfuse yet |
A conditional dependency whose condition is false renders nothing at all, so a release that leaves both off is byte-identical to one built before they existed. They are still fetched into charts/ by helm dependency build, because Helm refuses to render a chart whose declared dependencies are missing from the directory regardless of condition — which is why CI builds dependencies before it lints.
Install
From a source checkout:
helm upgrade --install litellm helm-charts/litellm \
--namespace litellm \
--create-namespace \
--values my-values.yaml
From GHCR:
helm upgrade --install litellm \
oci://ghcr.io/sassoftware/sas-retrieval-agent-manager-examples/charts/litellm \
--namespace litellm \
--create-namespace \
--values my-values.yaml
Omit --version to install the newest published chart. Add --version <x.y.z> to pin an exact version.
The default values install a working proxy with one mock model, so a bare helm install starts and passes its health probes with nothing else configured. Replace models before you send real traffic.
Reach it without routing:
kubectl -n litellm port-forward svc/litellm 4000
The OpenAI-compatible API is then at http://localhost:4000, and the Admin UI at http://localhost:4000/ui.
Versions
appVersion in Chart.yaml is the upstream LiteLLM release this chart targets, and it is the default container tag. image.tag is empty by default and falls back to it, so one edit moves the proxy and the migration Job together. Nothing can pin them apart: both resolve the image through the same template helper.
Set image.tag only to run a release other than the one the chart targets.
The chart rejects
latestandmain-latest. The proxy and the migration Job resolve the tag independently at pull time, so a tag that moves between the Job finishing and a pod restarting runs a different proxy against the schema that Job just migrated.main-stable, LiteLLM’s documented floating production tag, is allowed.
version in Chart.yaml is the chart’s own version and is managed by CI, which resolves the next free patch from the registry on every publish.
Model management
modelManagement.mode decides where the model catalog lives. It is the single most consequential value in this chart, and it is worth choosing deliberately. Everything else works the same either way.
config |
database |
|
|---|---|---|
| Catalog lives in | config.yaml, from models in your values |
PostgreSQL |
| Adding a model | edit values, helm upgrade, pods roll |
Admin UI or POST /model/new, no redeploy |
| Reproducible from Git | yes | no — the database is the source of truth |
| Needs PostgreSQL | no | yes |
| Migration Job | not rendered | runs prisma migrate deploy before the proxy |
| Virtual keys, teams, budgets | unavailable | available |
| Spend tracking | unavailable | available |
| Audit of who changed what | Git history | the database |
Those last three rows usually decide it. Virtual keys and spend tracking have no config.yaml representation, so any deployment that hands out per-team keys or bills usage back needs database.
Config flow
modelManagement:
mode: config
models:
- model_name: gpt-4o
litellm_params:
model: azure/gpt-4o
api_base: https://example.openai.azure.com/
api_key: os.environ/AZURE_OPENAI_API_KEY
- model_name: claude-sonnet
litellm_params:
model: anthropic/claude-sonnet-4-20250514
api_key: os.environ/ANTHROPIC_API_KEY
environmentSecrets:
- litellm-provider-keys
models is appended to proxyConfig.model_list and rendered into a ConfigMap. The pod template carries a checksum of that ConfigMap, so helm upgrade restarts the proxy whenever the catalog changes. Without the checksum the new file would sit in the ConfigMap unread until an unrelated change happened to roll the pods.
Everything that is not a model goes in proxyConfig, which is rendered verbatim:
proxyConfig:
litellm_settings:
drop_params: true
request_timeout: 600
callbacks: ["prometheus"]
router_settings:
routing_strategy: simple-shuffle
general_settings:
proxy_batch_write_at: 60
To manage config.yaml entirely outside this chart, set config.existingConfigMap.name. models and proxyConfig are then ignored, and no ConfigMap is rendered.
Database flow
modelManagement:
mode: database
database:
host: litellm-pg.postgres.database.azure.com
name: litellm
sslMode: verify-full
existingSecret:
name: litellm-db # keys: username, password
redis:
host: litellm-redis.example.com
existingSecret:
name: litellm-redis # key: password
The chart sets general_settings.store_model_in_db, renders the migration Job, and starts the proxy with DISABLE_SCHEMA_UPDATE=true so that N replicas do not race one database on every rollout.
config.yaml is still mounted and still owns everything that is not a model: callbacks, router settings, guardrails, and the rest of general_settings. Only the catalog moves.
Set modelManagement.seedFromConfig: true to also render models into config.yaml as an always-present floor, for example a house model that must exist even if someone empties the database. Config entries are read-only in the UI.
Turning the database on without moving the catalog
database.enabled is independent of modelManagement.mode. Setting it while staying in config mode gives you virtual keys, teams, budgets, and spend tracking, while the catalog stays in Git:
modelManagement:
mode: config
database:
enabled: true
host: litellm-pg.example.com
existingSecret:
name: litellm-db
This is a good middle ground for a platform team that wants reviewable model changes and per-team keys at the same time.
Switching flows later
config to database is additive: point database.* at a server, flip the mode, upgrade. The migration Job creates the schema, and the catalog you already had keeps loading if you set seedFromConfig.
database to config drops every runtime-created model, key, team, and spend record from the serving path. Export anything you need first; the chart cannot migrate it for you.
Provider credentials
Never put a literal API key in models or proxyConfig. Both are rendered into a ConfigMap, which is world-readable to anything with get configmap in the namespace, and both end up in the Helm release secret in plain text.
Put keys in a Secret, list it under environmentSecrets, and reference them as os.environ/<KEY>:
kubectl -n litellm create secret generic litellm-provider-keys \
--from-literal=AZURE_OPENAI_API_KEY=... \
--from-literal=ANTHROPIC_API_KEY=...
environmentSecrets:
- litellm-provider-keys
models:
- model_name: gpt-4o
litellm_params:
model: azure/gpt-4o
api_key: os.environ/AZURE_OPENAI_API_KEY
Every key in a listed Secret becomes an environment variable in the pod, so the names in the Secret are exactly what os.environ/ resolves against. environmentConfigMaps works the same way for values that are not secret.
In the database flow, credentials added through the Admin UI are encrypted in the database with LITELLM_SALT_KEY. Set it once through environmentSecrets and never change it: it encrypts stored provider keys, and changing it makes them unreadable.
Routing
Shared settings live under routing; each implementation adds its own block. routing.hosts, routing.path, routing.tls, routing.annotations, and routing.labels apply to all of them.
The proxy serves the OpenAI API, the management API, and the UI from one port, so a single path prefix covers everything and no path-based dispatch is needed.
routing.path defaults to /litellm and must match serverRootPath, which is the prefix the proxy actually serves under — see Sharing a hostname.
Forwarded headers
TLS terminates at the ingress, so the request arriving at the pod is plaintext. Left to itself the proxy takes that at face value and emits http:// URLs — the trailing-slash redirect on /ui sends the browser to a plaintext address, downgrading a release this chart published over HTTPS.
uvicorn already parses the X-Forwarded-* headers that say otherwise, but ignores them unless the sender is trusted, and its default is 127.0.0.1 — which in Kubernetes is never the ingress. trustedProxies supplies that list, and defaults to *:
trustedProxies: ["*"]
* trusts any client that can reach the pod. The Service is ClusterIP, so that means in-cluster callers rather than the internet, and the only header they could forge is consulted for the client IP in logs and for LiteLLM’s optional allowed_ips. On a cluster without NetworkPolicy, narrow it to whatever fronts the release:
trustedProxies: ["10.244.0.0/16"] # pod CIDR
CIDR notation needs uvicorn 0.30 or newer, which the chart’s pinned image carries. Setting trustedProxies: [] leaves the variable unset and restores uvicorn’s own default.
Ingress
routing:
hosts: [litellm.example.com]
tls:
enabled: true
secretName: litellm-tls
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-body-size: "32m"
ingress-nginx rejects a dot in an
ExactorPrefixpath through its admission webhook, and servesImplementationSpecificas a plain prefix anyway. The chart therefore downgrades a dotted path toImplementationSpecificwhenclassNameisnginx, so a path such as/v1.0is accepted instead of being rejected at apply time.
Raise the read timeout. A streaming completion holds the connection open far longer than the 60-second default, and the symptom of leaving it alone is truncated responses under load rather than an obvious error.
routing.ingress.extraPaths adds further paths to each host, for example to send one prefix to a different Service.
HTTPRoute (Gateway API)
routing:
hosts: [litellm.example.com]
httpRoute:
enabled: true
parentRefs:
- name: eg
namespace: envoy-gateway-system
sectionName: https
timeouts:
backendRequest: 0s
routing.tlsis ignored here on purpose. In Gateway API the certificate belongs to the Gateway’s listener, not to the route attached to it. Put the Secret in the listener’stls.certificateRefs; a chart that also rendered it here would silently do nothing.
timeouts.backendRequest: 0s disables the request timeout. Without it a long streaming completion is cut off at the implementation’s default, which for Envoy Gateway is 15 seconds.
filters and extraRules are passed through untouched, so a URLRewrite, a RequestHeaderModifier, or an extra rule for a second hostname all work without the chart knowing about them.
HTTPProxy (Contour)
routing:
hosts: [litellm.example.com]
tls:
enabled: true
secretName: litellm-tls
httpProxy:
enabled: true
ingressClassName: contour
timeoutPolicy:
response: infinity
idle: 600s
retryPolicy:
count: 2
retryOn: ["5xx"]
Prefer this over a plain Ingress on Contour. The retry policy, response timeout, and load balancer policy become schema-validated fields the API server checks, instead of controller-specific annotations that nothing validates and that fail silently when misspelled.
timeoutPolicy.response: infinity is the Contour equivalent of the timeout note above.
One HTTPProxy carries one virtual host. The chart fails the render rather than silently publishing only the first entry when routing.hosts has more than one.
Sharing a hostname another release already owns
Contour allows one root HTTPProxy per FQDN, and a root is any proxy that declares a virtualhost. When another application already owns the hostname this release has to answer on, a second root is rejected — and it is undefined which of the two loses, so the attempt risks taking the incumbent down.
The way through is delegation: that application includes this one under a path prefix, and this release renders a child proxy with no virtualhost of its own.
In the owning release’s root HTTPProxy:
includes:
- name: litellm
namespace: litellm
conditions:
- prefix: /litellm
And here:
serverRootPath: /litellm # must match the delegated prefix
routing:
path: /litellm
httpProxy:
enabled: true
delegated: true
routing.hosts and routing.tls are not needed and are ignored: the child inherits both from the parent, so it needs no certificate of its own. TLS passthrough is rejected, since it is a property of a virtualhost the child does not have.
The generated route matches /, not routing.path. Contour concatenates the parent’s condition with the child’s, so repeating the prefix would publish /litellm/litellm. serverRootPath must still match the delegated prefix, because that is what the proxy itself serves under.
A child is reachable only while the parent includes it. Delete or reconfigure the parent and Contour reports the child as orphaned and ignores it — the release keeps running and serves nothing.
kubectl get httpproxy <name> -o jsonpath='{.status.description}'is what tells you.
Delegation also makes the mount root useful. The proxy answers its own root with a plain "LiteLLM: RUNNING" string, so a browser opening /litellm lands on nothing helpful; requestRedirectPolicy on an exact match sends it to the Admin UI instead:
routing:
httpProxy:
extraRoutes:
- conditions:
- exact: /
requestRedirectPolicy:
path: /litellm/ui/
statusCode: 302
An exact match is what keeps this to the bare prefix — a prefix condition would redirect /litellm/v1/... as well. This works for a root HTTPProxy too; it is only unavailable on a plain Ingress, where Contour ignores the redirect annotations.
Route (OpenShift)
routing:
hosts: [litellm.example.com] # omit to let OpenShift generate one
tls:
enabled: true
route:
enabled: true
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
Leave routing.hosts empty and the router generates a hostname from the route name, the namespace, and the cluster’s apps domain.
A passthrough Route cannot carry a path, so the chart omits spec.path when termination is passthrough and sends the whole host to the proxy.
Migrating between implementations
Enable both, cut traffic over at DNS, then disable the old one:
routing:
hosts: [litellm.example.com]
ingress:
enabled: true # still serving
className: nginx
httpRoute:
enabled: true # new path, warming up
parentRefs:
- name: eg
Both objects point at the same Service, so neither takes the proxy down.
Sharing a hostname
At the domain root LiteLLM claims /ui, /v1, /docs, /health, /metrics and more. That is an awkward neighbour for an application that owns the site, and it is the usual reason to deploy this chart as an add-on rather than as the main tenant.
The chart therefore ships mounted under /litellm, via serverRootPath:
serverRootPath: /litellm
routing:
path: /litellm
The Admin UI is at /litellm/ui and the OpenAI-compatible base URL is https://<host>/litellm/v1. Nothing outside the prefix is served, so every root path stays available to the other application.
To claim the domain root instead, clear both:
serverRootPath: ""
routing:
path: /
Point clients at the prefix. With the default,
OPENAI_BASE_URLishttps://<host>/litellm/v1; a client configured forhttps://<host>/v1gets a 404 from the proxy rather than a connection error, which tends to read as a missing model.
API docs
LiteLLM serves the Swagger UI at its mount root — upstream’s DOCS_URL defaults to / — so opening the release in a browser lands on the API reference rather than the Admin UI. This chart moves it to /docs:
docs:
enabled: true
path: /docs
| URL with the chart defaults | |
|---|---|
| Swagger UI | https://<host>/litellm/docs |
| Admin UI | https://<host>/litellm/ui |
Set path: / to restore upstream’s behaviour, or enabled: false to drop the docs page and the OpenAPI schema together — both, because Swagger reads the schema, and removing one without the other leaves a page that renders an error. A path under /ui fails the render, since it would be shadowed by the Admin UI.
With the docs moved, the mount root itself returns LiteLLM’s plain
"LiteLLM: RUNNING"health string. The proxy has no route that redirects its root to the Admin UI, so link people to/litellm/uidirectly.The health endpoints keep answering at the root as well, which is why the probes in this chart are not prefixed. Verified against the image: with
SERVER_ROOT_PATHset,/health/readinessand/litellm/health/readinessboth return 200 while/uireturns 404 and/litellm/uireturns 200. The UI’s own asset URLs are rewritten by the image at runtime.
The chart fails the render if routing.path does not match the prefix, because publishing / anyway would defeat the point, and publishing a different prefix would serve 404s.
This changes the client-facing API base URL, so existing callers need updating.
Single sign-on
Signs in to the Admin UI with an OpenID Connect provider instead of the shared master key, through LiteLLM’s generic OIDC support.
SSO is a LiteLLM enterprise feature. Since v1.76.0 it is free for up to five users; past that it needs a licence in
license.existingSecret.
sso:
enabled: true
clientId: litellm
authorizationEndpoint: https://idp.example.com/realms/r/protocol/openid-connect/auth
tokenEndpoint: https://idp.example.com/realms/r/protocol/openid-connect/token
userinfoEndpoint: https://idp.example.com/realms/r/protocol/openid-connect/userinfo
The client secret is generated on first install and reused on every later upgrade, so upgrading never invalidates the provider-side registration. Supply your own with sso.clientSecret.existingSecret.
sso.proxyBaseUrl is derived from routing.hosts[0], routing.tls.enabled and routing.path when left empty, including any serverRootPath prefix. Deriving it keeps the redirect URI from drifting out of step with the Ingress. The provider must accept <proxyBaseUrl>/sso/callback.
Why the three endpoints are separate
They are not called by the same party, and they are frequently not interchangeable.
| Setting | Called by | Must be reachable from |
|---|---|---|
authorizationEndpoint |
the browser | the user’s machine |
tokenEndpoint |
the proxy pod | inside the cluster |
userinfoEndpoint |
the proxy pod | inside the cluster |
A provider published through a cloud load balancer is often unreachable from inside the same cluster, because the load balancer does not hairpin — yet it still advertises its public URL in its discovery document. Copying all three from that document produces a sign-in that redirects correctly and then fails at the token exchange. Point the browser-facing one at the public address and the other two at the in-cluster Service.
Keycloak bootstrap
sso.keycloakBootstrap adds a one-shot Job that configures the Keycloak side to match:
sso:
keycloakBootstrap:
enabled: true
url: http://keycloak.identity.svc.cluster.local:8080
realm: myrealm
admin:
existingSecret:
name: keycloak-admin # keys: username, password
It creates, in an existing realm:
- A confidential OIDC client with the right redirect URIs, including LiteLLM’s
/sso/debug/callbackclaim-dumping route. - Two client roles carrying LiteLLM’s own role names —
proxy_adminandinternal_userby default. - Two groups,
litellm-adminsandlitellm-users, granted those roles. - A protocol mapper putting the client roles into a flat
rolesclaim. - Optionally an
llmadminaccount in the admin group.
Step 4 is the one that is easy to miss. Keycloak nests client roles under resource_access.<client>.roles, which LiteLLM does not read, so without the mapper every user signs in carrying no role at all.
Client roles rather than realm roles, and dedicated groups rather than existing ones, so nothing this chart creates appears at realm scope or changes another application’s authorization. Point groups at existing group names if you would rather reuse them.
Every step reads before it writes, so the Job is safe to re-run and repairs a half-applied earlier run rather than duplicating it. It never creates realms.
Realms usually carry a password policy. The generated
llmadminpassword ends in a fixedAa1!so it satisfies the commonupperCase(1) lowerCase(1) digits(1) specialChars(1)rules by construction — relying on chance would fail roughly one deploy in four hundred. If your policy is stricter, setsso.keycloakBootstrap.adminUser.password.value; the Job names the policy in its error output when Keycloak rejects one.
Read the account password with:
kubectl -n litellm get secret litellm-sso \
-o jsonpath='{.data.admin-user-password}' | base64 -d
Database
The chart reads database credentials from a Secret only and accepts no inline password.
It emits the discrete DATABASE_HOST, DATABASE_PORT, DATABASE_USERNAME, DATABASE_PASSWORD, and DATABASE_NAME variables and lets the proxy assemble and percent-encode the URL. This matters: a password containing @, /, or ? corrupts a hand-assembled connection string, and the resulting failure is an opaque Prisma connect error rather than anything that names the password as the cause.
kubectl -n litellm create secret generic litellm-db \
--from-literal=username=litellm \
--from-literal=password='<password>'
database:
enabled: true
host: litellm-pg.example.com
port: 5432
name: litellm
sslMode: verify-full
existingSecret:
name: litellm-db
usernameKey: username
passwordKey: password
Set database.existingSecret.urlKey instead to supply a whole connection URL from one Secret key, for parameters the discrete fields cannot express. The discrete host, port, and name are then not emitted.
Workload identity
For a managed database with IAM authentication, no password is read at all. The proxy mints a token at startup.
database:
auth:
mode: azureEntra # or awsIam
serviceAccount:
create: true
annotations:
azure.workload.identity/client-id: <client-id>
The Secret is still required for the username. migrationJob.serviceAccountName gives the Job its own identity when it needs one distinct from the proxy’s.
TLS to the database
sslMode: verify-full with a CA bundle is what a managed PostgreSQL service expects. Mount the bundle and point at it:
database:
sslMode: verify-full
sslRootCert: /certs/ca.pem
volumes:
- name: db-ca
configMap:
name: rds-ca
volumeMounts:
- name: db-ca
mountPath: /certs
readOnly: true
volumes and volumeMounts are applied to the migration Job as well as the proxy, so migrations verify the certificate on the same terms.
Connection budget
Each worker opens its own pool, so a pod holds numWorkers × connection_limit upstream connections and the total scales with maxReplicas, not with the replicas running today. An autoscaler at the default maxReplicas: 100 can ask for far more connections than a stock PostgreSQL accepts.
Either size autoscaling.maxReplicas from what the database can serve, or turn on the in-pod pooler:
database:
connectionPool:
enabled: true
maxDbConnections: 20
maxClientConn: 1000
With the pooler a pod holds at most maxDbConnections upstream connections however many workers run. Set database.disablePreparedStatements: true in front of an external transaction-mode pooler such as PgBouncer or RDS Proxy.
Read replica
database:
readReplica:
enabled: true
host: litellm-pg-reader.example.com
Read-only queries go to the replica and writes continue to the writer. Fields left empty fall back to the writer’s values, including the credentials Secret.
Creating the database
Everything above assumes the database, the login role, and the schema already exist — the usual case, because a DBA provisioned them. When they do not, database.init adds a one-shot Job that creates them.
It is off by default. Turning it on does not change how the proxy connects; it only adds a step in front.
database:
host: litellm-pg.example.com
name: litellm
schema: litellm
existingSecret:
name: litellm-db # keys: username, password
init:
enabled: true
admin:
existingSecret:
name: litellm-pg-admin # keys: username, password
Which credential is which
There are two, and conflating them is the easy mistake:
| Used by | Lifetime | |
|---|---|---|
database.existingSecret |
the proxy, every day | permanent |
database.init.admin |
this Job, once | erased when the Job succeeds |
database.existingSecret is the input to the Job, not something it reads back. The Job creates a login role with that username and password, makes it the owner of database.name and database.schema, and exits. The proxy then authenticates as that role. One Secret, written once by you, used by both.
The admin credential is separate because creating a database and a role needs privileges the proxy must never hold.
What it runs
Against the maintenance database named by admin.database (postgres by default, because CREATE DATABASE cannot run inside the database being created):
CREATE ROLEfor the service user, orALTER ROLEto reset its password to match the Secret.-
CREATE DATABASEowned by that role.Then, connected to the new database:
GRANT ALL PRIVILEGES ON DATABASEto the role.CREATE SCHEMA ... AUTHORIZATIONthe role, andGRANT ALL ON SCHEMA.ALTER ROLE ... SET search_path, so an interactivepsqlsession as that role lands in the right schema.
Each step is skipped when the object already exists, so the Job is safe to re-run. Step 4’s GRANT runs even on an existing schema: PostgreSQL 15 stopped granting CREATE on public to everyone, and without it prisma migrate deploy fails with permission denied for schema.
Turn individual steps off with createDatabase, createUser, and createSchema when a DBA has already done part of the work.
Password rotation
The ALTER ROLE in step 1 is unconditional, so changing the password in database.existingSecret and upgrading resets the role to match. Without that, rotating the Secret would leave the proxy unable to log in until someone fixed the role by hand.
Erasing the admin credentials
With inline admin.username / admin.password, the chart renders a Secret, the Job uses it, and a second container erases both keys from it the moment the Job succeeds.
The ordering is structural rather than best-effort. The psql step is an init container and the cleanup is the only ordinary container, so Kubernetes will not start the cleanup until the init container has exited 0. A failed initialization therefore never reaches the erase step, and the credentials survive for the retry.
The erase is a JSON merge patch setting both keys to null. That removes them whether or not they are still there, which makes it idempotent with no conditional logic — and that in turn is what lets it invoke kubectl directly, with no shell. The upstream registry.k8s.io/kubectl image is distroless and has no /bin/sh, so a shell script here would not run at all.
The Secret object itself is kept, with its data keys gone. The Role carries get and patch on that one Secret name and deliberately no delete, so this chart never creates a Role that could delete Secrets in your namespace.
Erasing the Kubernetes Secret does not remove an inline password from the Helm release secret, which
helm get valuescan read. It is also restored for the length of the hook window on every upgrade, because re-running the Job needs it.To keep the admin password out of the release entirely, create the Secret yourself and name it under
admin.existingSecret. The chart then modifies nothing — it only destroys what it created — and rotating that Secret afterwards is yours to do.
The cleanest sequence for a one-time bootstrap is therefore: install with init.enabled: true, confirm the Job succeeded, then set init.enabled: false for every later upgrade.
Ordering
The Job is a Helm hook at weight -5, ahead of the migration Job at 1. The chart rejects a weight that is not below the migration’s, because the failure it prevents surfaces as a confusing Prisma error about a database that does not exist rather than as a misconfigured value.
Under a GitOps controller, turn hooks.helm.enabled off and hooks.argocd.enabled on; the sync waves keep the same order.
The wave is the hook weight. They express the same thing in two dialects, so the
chart does not ask for both — there is no syncWave value to keep in step with
the weight beside it.
A wave is only meaningful relative to everything else in the same ArgoCD Application, which the chart cannot see: how the surrounding Applications are numbered, or whether the database is provisioned by this chart at all. Where the defaults do not fit, set the annotation directly — it is applied after the chart’s own and so takes precedence:
migrationJob:
annotations:
argocd.argoproj.io/sync-wave: "12"
Ordinary failures
password authentication failedfor the admin user:admin.usernameis not a login that mayCREATE DATABASEandCREATE ROLE. On a managed service this is the server administrator, notpostgres.- The Job waits and then times out: the server is not reachable from the cluster.
waitTimeoutbounds this at 300 seconds. permission denied to create database: the admin user exists but lacksCREATEDB.
Read what it actually did:
kubectl -n litellm logs job/litellm-db-init -c db-init
Redis
Redis is the proxy’s coordination store: cross-pod rate limits, spend tracking, and the pod lock manager that elects a single owner for shared background jobs.
redis:
host: litellm-redis.example.com
port: 6379
existingSecret:
name: litellm-redis
passwordKey: password
The chart wires REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD, and renders a general_settings.coordination_redis block. A coordination_redis block you write in proxyConfig always wins. Set redis.coordination: false to wire only the environment variables, for example when Redis is there for response caching alone.
Without Redis, rate limits and spend counters are per-pod. A
tpmbudget is then enforced at roughlyreplicaCounttimes what you configured, and budget resets run once per pod instead of once. The chart prints this warning on install wheneverreplicaCountis above one or autoscaling is on and the release has no Redis.
For Sentinel, set redis.sentinel.enabled and redis.sentinel.masterSet; the coordination block is then rendered with sentinel_nodes and service_name instead of host and port.
Letting the chart deploy one
valkey.enabled: true deploys a Valkey — the Redis fork, same wire protocol — through the Valkey project’s own chart, and points everything above at it:
valkey:
enabled: true
cache:
enabled: true
That is the whole configuration. The chart derives, from the subchart’s own values so the two cannot drift:
| Variable | Derived from |
|---|---|
REDIS_HOST |
the Valkey primary Service, fully qualified with valkey.clusterDomain |
REDIS_PORT |
valkey.service.port |
REDIS_PASSWORD |
the Secret the subchart reads the default user’s password from |
valkey.nameOverride and valkey.fullnameOverride are honoured, because the address is built with the same logic the subchart names its Service with rather than by assuming <release>-valkey.
redis.host always wins. Set it and the bundled Valkey is not consulted for the address, even if it is also deployed — a subchart default must never be able to repoint a release at a different server than the one someone typed. That is also how you put the bundled Valkey behind an explicit name.
Every other key under valkey is passed through to the subchart untouched; see its values reference. This chart’s schema accepts them without listing them, which is the one place it is not strict about unknown keys: the subchart’s value surface is not ours to enumerate.
The subchart ships with authentication off, so any pod that can reach the Service can read and write the cache. On a shared cluster, turn it on:
valkey: enabled: true auth: enabled: true usersExistingSecret: litellm-valkey-acl # key `default` aclUsers: default: permissions: "~* &* +@all"With
usersExistingSecretthe password is yours to create and rotate, and the chart reads thedefaultuser’s key from it. With an inlineaclUsers.default.passwordthe subchart writes the password into its own<release>-valkey-authSecret — and into the Helm release secret — and the chart readsdefault-passwordfrom there. Either way the proxy receives asecretKeyRef, never a literal. Enablingauthwith only anaclConfigblock fails the render: the chart cannot read a password out of an ACL file it does not parse.
What it is and is not for: a response cache is reconstructible by definition, so losing the pod costs latency and provider spend, never data. Spend records and virtual keys are not — those live in PostgreSQL. Treat the bundled Valkey as a cache unless you enable persistence (valkey.replica.persistence) and back it up like anything else.
Sentinel and the bundled Valkey are mutually exclusive, and the chart says so rather than rendering a sentinel_nodes block pointing at a server that has no Sentinel in front of it.
Response cache
Cache completion responses in Redis, so an identical request is answered from the cache instead of being billed and rate-limited by the provider again. The cache is shared by every replica — one pod’s hit serves the next pod’s request, which is the reason to put it in Redis rather than in each worker’s memory.
cache:
enabled: true
ttl: 3600
namespace: litellm-prod
supportedCallTypes: [acompletion, aembedding]
redis:
host: redis.example.com
existingSecret:
name: litellm-redis
This renders into config.yaml:
litellm_settings:
cache: true
cache_params:
type: redis
host: os.environ/REDIS_HOST
port: os.environ/REDIS_PORT
password: os.environ/REDIS_PASSWORD
ttl: 3600
namespace: litellm-prod
supported_call_types: [acompletion, aembedding]
The address never appears in config.yaml as a literal: the same REDIS_* variables the coordination store uses are referenced, so one Redis setting feeds both and there is one place to change it.
| Value | Effect |
|---|---|
cache.ttl |
Seconds an entry stays valid. Empty leaves LiteLLM’s default, which never expires entries and relies on Redis eviction |
cache.namespace |
Key prefix, so several releases can share one Redis without reading each other’s entries |
cache.supportedCallTypes |
Which call types are cached. Empty caches every type LiteLLM knows |
Embeddings are the usual reason to turn caching on: the same document chunk is embedded identically every time it is re-indexed, and each re-index is billed again without a cache.
What you have written always wins, whole-key. A cache or cache_params block in proxyConfig is left exactly as it is rather than merged into, because a half-merged cache_params would match neither what you asked for nor what this chart documents. That is also the escape hatch for the backends the chart does not model:
cache:
enabled: false
proxyConfig:
litellm_settings:
cache: true
cache_params:
type: s3
s3_bucket_name: litellm-cache
cache.type accepts redis only. It is the one backend the chart can wire end to end, and the one that behaves correctly above a single worker: LiteLLM’s in-memory cache is per worker process, so a two-replica, four-worker deployment would hold eight unrelated caches and hit on roughly one request in eight.
cache.enabled with no Redis at all — neither redis.host nor valkey.enabled — fails the render. A proxy in that state starts happily and then answers every request from the provider while logging connection errors, which is an expensive way to find out.
Check it is working after the first repeated request:
kubectl -n litellm exec deploy/litellm -- curl -s localhost:4000/cache/ping
Migration Job
In the database flow the chart runs prisma migrate deploy in a Job ahead of the proxy, and starts the proxy with DISABLE_SCHEMA_UPDATE=true for as long as that Job owns the schema. Without this, every replica races to push the schema on every rollout.
DISABLE_SCHEMA_UPDATE is emitted last in both workloads, after envVars and extraEnvVars, so a user-supplied value cannot silently shadow it under last-wins duplicate-environment semantics.
A Job’s pod template is immutable, so a stable-named Job cannot be updated in place: the first
helm upgradethat changes the image tag or any environment variable would be rejected by the API server.migrationJob.hooks.helm.enabledis on by default and deletes and recreates the Job ahead of each install and upgrade, which is what makes repeated upgrades work.
Under a GitOps controller that runs the Job itself, turn the Helm hook off and migrationJob.hooks.argocd.enabled on. Running both migrates twice.
migrationJob.activeDeadlineSeconds defaults to 1800 and is a wall-clock budget for the whole Job, shared across every retry. Without it a migration blocked on a lock never fails, and a release waiting on the hook never returns.
Secrets
Master key
The master key authenticates admin API calls and is the Admin UI password.
It is generated once as sk-... on first install and stored in <release>-masterkey. Every later upgrade reads the value back out of the live Secret, so upgrading never rotates the key and never invalidates the virtual keys derived from it.
kubectl -n litellm get secret litellm-masterkey \
-o jsonpath='{.data.masterkey}' | base64 -d
Point masterKey.existingSecret.name at a Secret you manage to take it over. The chart then renders no Secret of its own, which is the right choice when an external secrets operator owns the namespace.
masterKey.value sets it inline. It ends up in the Helm release secret in plain text, so prefer existingSecret outside development.
License and UI login
license:
existingSecret:
name: litellm-license # key: license
ui:
credentialsSecret:
name: litellm-ui # keys: username, password
Without ui.credentialsSecret the Admin UI is reached with the master key, which means sharing the credential that also authorises every admin API call.
Scaling and availability
replicaCount: 3
numWorkers: "1"
resources:
requests:
cpu: "1"
memory: 4Gi
limits:
cpu: "1"
memory: 4Gi
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
targetCPUUtilizationPercentage: 60
pdb:
enabled: true
maxUnavailable: 1
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: litellm
resources is unset by default so the chart installs on a small cluster, and so an upgrade never leaves a pod Pending. Production should set it. One worker at database-connected steady state needs roughly 1 CPU and 4Gi, and both scale with numWorkers.
4Gi is a floor rather than a target. The Prisma query engine’s resident memory is a high-water mark that grows to the largest statement it has ever run and is not returned to the OS, so a pod’s floor ratchets up to its worst-ever write. Provision below that and one large write is enough to get the pod OOM-killed, which surfaces as a crash loop under traffic that looks unremarkable on every other metric.
One worker per pod is the right default on Kubernetes: it keeps the HPA reading CPU against a single process and makes rolling restarts drain cleanly.
autoscaling.targetMemoryUtilizationPercentageis deliberately unset. For the same high-water-mark reason, a memory target reads the largest write a pod ever did rather than its current load, so replicas ratchet up after one large write and never scale back in. Scale on CPU, or on the per-pod request and token metrics.
60 percent rather than something higher because a new replica is not useful the moment it is created: the startup probe allows up to 300 seconds. A target that only trips near saturation adds capacity minutes after it was needed.
targetRequestsPerSecond and targetTokensPerSecond render autoscaling/v2 Pods metrics on the proxy’s own counters. Both need a Prometheus Adapter serving those names on custom.metrics.k8s.io.
Graceful shutdown
terminationGracePeriodSeconds: 90
lifecycle:
preStop:
httpGet:
path: /health/drain
port: 4000
httpHeaders:
- name: X-Drain-Token
value: <same value as drain_endpoint_token>
proxyConfig:
general_settings:
enable_drain_endpoint: true
drain_endpoint_token: <token>
/health/drain marks the pod NotReady and blocks only until in-flight requests actually finish, instead of the fixed sleep that a preStop hook usually contains. Keep terminationGracePeriodSeconds a few seconds above GRACEFUL_SHUTDOWN_TIMEOUT (30 by default) to leave room for teardown before SIGKILL.
Observability
metricsServer:
enabled: true
port: 4001
serviceMonitor:
enabled: true
interval: 15s
proxyConfig:
litellm_settings:
callbacks: ["prometheus"]
metricsServer moves Prometheus /metrics onto a separate process and port with its own ClusterIP Service, so a scrape never lands on an inference worker. The ServiceMonitor then scrapes that Service instead of the proxy port.
The proxy port serves
/metricsbehind virtual-key auth and answers an unauthenticated scrape with 401, so enablemetricsServerwheneverserviceMonitoris on. The metrics port itself carries no auth: keep it off public routing. The chart rejects a metrics port equal to the service port.
For production logging, turn the proxy’s own verbosity down and emit structured logs:
logLevel: ERROR
proxyConfig:
litellm_settings:
set_verbose: false
json_logs: true
Tracing with Langfuse
Prometheus answers how much and how fast. Langfuse answers what was actually asked and answered: prompt, response, model, latency, token counts, and cost, grouped into traces and attributable to a virtual key.
langfuse:
enabled: true
host: http://langfuse-web.observability.svc.cluster.local:3000
existingSecret:
name: litellm-langfuse
kubectl -n litellm create secret generic litellm-langfuse \
--from-literal=public-key=pk-lf-... \
--from-literal=secret-key=sk-lf-...
That sets LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY, and LANGFUSE_SECRET_KEY on the proxy and appends langfuse to litellm_settings.success_callback and failure_callback.
Appends. A callback list you wrote in proxyConfig keeps every entry it had — callbacks are a set of independent sinks, and adding Langfuse to a release that already ships traces to Datadog must not switch Datadog off. Listing langfuse yourself is also fine: it is not added twice.
The keys are project keys (pk-lf-… / sk-lf-…), issued in the Langfuse UI under Settings → API keys, and they come from a Secret in both modes below. This chart accepts no inline Langfuse key, for the same reason it accepts no inline database password: a project key grants write access to every trace in the project, and an inline value would sit in the Helm release secret as well as in the pod spec.
existingSecret.publicKeyKey and .secretKeyKey name the keys inside that Secret, and default to public-key and secret-key. Point them at whatever an existing Secret already uses rather than copying it.
Callbacks are on the request path. A Langfuse that is unreachable costs every request the callback’s timeout, so prefer an address in the same cluster, and treat
langfuse.hostas production infrastructure rather than a best-effort sink.
Deploying Langfuse as part of this release
langfuse.deploy.enabled: true adds Langfuse itself to this release and defaults langfuse.host to its in-cluster web Service. It is off by default, and off is the recommendation.
Langfuse is a platform, not a sidecar. Turning this on adds, into a release whose own workload is one stateless proxy:
Component Why Langfuse needs it Langfuse web + worker Deployments the UI/API, and the ingestion pipeline PostgreSQL transactional store ClickHouse cluster analytics store for traces and observations Valkey ingestion queue — a second one, unrelated to valkey.enabledSeaweedFS S3-compatible storage for event and media blobs That is roughly ten workloads and several PersistentVolumeClaims, with their own upgrade and backup story, sharing a release lifecycle with a proxy you will want to upgrade far more often.
It also has cluster-level prerequisites this chart neither installs nor checks for you:
- the Altinity ClickHouse operator (
clickhouse.com/v1alpha1), and - cert-manager, which that operator requires.
Without them the subchart fails its own render with a message naming the missing CRD. For an offline render — helm template, or a GitOps diff against a cluster whose CRDs are not visible — set langfuse.clickhouse.crdCheck: false.
Deploying the server does not create project keys: nothing issues them at install time. Sign in, create an organization and a project, issue a key pair, and put it in the Secret. langfuse.existingSecret.name is required either way, and traces are rejected until the keys in it are real.
kubectl -n litellm port-forward svc/litellm-langfuse-web 3000
Every other key under langfuse is passed through to the subchart untouched — langfuse.postgresql, langfuse.clickhouse, langfuse.s3, and langfuse.langfuse for the server’s own settings; see its values reference.
Use it for an evaluation cluster, or where Langfuse genuinely has no other home. For anything you intend to keep, run Langfuse as its own release and point langfuse.host at it: upgrading this proxy should not be able to take the observability stack down with it.
Running read-only
readOnlyRootFilesystem is supported. The image writes to the filesystem on startup, so the chart adds emptyDir volumes for /tmp, /.cache, and /.npm automatically when you enable it:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
envVars:
LITELLM_MIGRATION_DIR: /tmp/migrations
PRISMA_BINARY_CACHE_DIR: /tmp/prisma
XDG_CACHE_HOME: /tmp/cache
The same volumes are added to the migration Job. The environment variables above point the components that still need a writable path at the emptyDir.
Values reference
Defaults are the shipped values.yaml. Every value is validated by values.schema.json, which rejects unknown keys, so a typo is caught at install time rather than ignored.
Model management
| Key | Description | Default |
|---|---|---|
modelManagement.mode |
config or database |
config |
modelManagement.seedFromConfig |
Also render models into config.yaml in database mode |
false |
models |
Model catalog, appended to proxyConfig.model_list |
one mock model |
proxyConfig |
The rest of config.yaml, rendered verbatim | {general_settings: {}} |
config.existingConfigMap.name |
Mount this ConfigMap instead of rendering one | "" |
config.existingConfigMap.key |
Key holding the config file | config.yaml |
config.mountPath |
In-container path of the config file | /etc/litellm/config.yaml |
Workload
| Key | Description | Default |
|---|---|---|
replicaCount |
Proxy replicas, ignored when autoscaling is on | 1 |
numWorkers |
Passed as --num_workers; empty lets the image decide |
"" |
listen |
Bind address inside the pod | 0.0.0.0 |
serverRootPath |
Prefix the proxy is mounted under; "" claims the root |
/litellm |
trustedProxies |
Senders whose X-Forwarded-* headers are believed |
["*"] |
docs.enabled |
Serve the Swagger UI and the OpenAPI schema | true |
docs.path |
Path for the Swagger UI; / is upstream’s mount root |
/docs |
image.repository |
Proxy image | ghcr.io/berriai/litellm |
image.tag |
Empty follows appVersion |
"" |
image.pullPolicy |
IfNotPresent |
|
imagePullSecrets |
[] |
|
serviceAccount.create |
Needed only for a cloud IAM binding | false |
serviceAccount.annotations |
Where the IAM binding goes | {} |
resources |
Requests and limits; set these in production | {} |
strategy |
Deployment update strategy | {} |
terminationGracePeriodSeconds |
90 |
|
topologySpreadConstraints |
[] |
|
nodeSelector, tolerations, affinity |
{}, [], {} |
|
podSecurityContext, securityContext |
{} |
|
lifecycle |
Prefer the /health/drain preStop hook |
{} |
volumes, volumeMounts |
Also applied to the migration Job | [] |
extraContainers, extraInitContainers |
Run through tpl |
[] |
command, args |
Override the image entrypoint | [] |
deploymentAnnotations, deploymentLabels |
{} |
|
podAnnotations, podLabels |
podLabels cannot set a selector key |
{} |
commonLabels, commonAnnotations |
Applied to every rendered object | {} |
Environment and secrets
| Key | Description | Default |
|---|---|---|
environmentSecrets |
Secret names mounted wholesale as env vars | [] |
environmentConfigMaps |
ConfigMap names mounted wholesale as env vars | [] |
logLevel |
Sets LITELLM_LOG; "" to source it elsewhere |
INFO |
envVars |
Plain key/value env entries | {} |
extraEnvVars |
Full Kubernetes EnvVar list, for valueFrom |
[] |
masterKey.value |
Inline key; prefer existingSecret |
"" |
masterKey.existingSecret.name |
Take over the master key Secret | "" |
license.existingSecret.name |
Enterprise license, into LITELLM_LICENSE |
"" |
ui.credentialsSecret.name |
Admin UI login | "" |
Service and probes
| Key | Description | Default |
|---|---|---|
service.type |
ClusterIP |
|
service.port |
Also the in-pod listen port | 4000 |
service.annotations |
{} |
|
service.loadBalancerClass |
LoadBalancer only | "" |
service.loadBalancerSourceRanges |
LoadBalancer only | [] |
metricsServer.enabled |
Separate metrics process and Service | false |
metricsServer.port |
Must differ from service.port |
4001 |
serviceMonitor.enabled |
Needs the Prometheus operator CRDs | false |
serviceMonitor.interval, .scrapeTimeout |
15s, 10s |
|
livenessProbe.* |
/health/liveliness |
see values.yaml |
readinessProbe.* |
/health/readiness |
see values.yaml |
startupProbe.* |
Allows up to 300s to first ready | see values.yaml |
Routing
| Key | Description | Default |
|---|---|---|
routing.hosts |
Hostnames; required for ingress and httpProxy |
[] |
routing.path |
Published prefix; must match serverRootPath |
/litellm |
routing.tls.enabled |
false |
|
routing.tls.secretName |
kubernetes.io/tls Secret |
"" |
routing.annotations, routing.labels |
Merged into every routing object | {} |
routing.ingress.enabled |
false |
|
routing.ingress.className |
"" |
|
routing.ingress.pathType |
Auto-downgraded for a dotted path on nginx | Prefix |
routing.ingress.extraPaths |
Further paths per host | [] |
routing.httpRoute.enabled |
false |
|
routing.httpRoute.parentRefs |
Required; the Gateways to attach to | [] |
routing.httpRoute.matches, .filters, .timeouts |
Passed through | [], [], {} |
routing.httpRoute.extraRules |
Appended after the generated rule | [] |
routing.httpProxy.enabled |
false |
|
routing.httpProxy.delegated |
Render a child proxy for a parent that includes it | false |
routing.httpProxy.ingressClassName |
"" |
|
routing.httpProxy.tls.* |
Falls back to routing.tls.secretName |
see values.yaml |
routing.httpProxy.timeoutPolicy, .retryPolicy, .loadBalancerPolicy |
Contour policies | {} |
routing.httpProxy.conditions, .extraRoutes, .includes |
[] |
|
routing.route.enabled |
false |
|
routing.route.tls.termination |
edge, reencrypt, or passthrough |
edge |
routing.route.tls.insecureEdgeTerminationPolicy |
Redirect |
|
routing.route.wildcardPolicy |
None |
Single sign-on
| Key | Description | Default |
|---|---|---|
sso.enabled |
Sign in to the Admin UI with OIDC. Enterprise; free to five users | false |
sso.proxyBaseUrl |
Browser-facing URL; derived from routing when empty |
"" |
sso.clientId |
litellm |
|
sso.scope |
openid profile email |
|
sso.clientState, .usePkce |
Required by some providers | "", false |
sso.authorizationEndpoint |
Browser-facing; must be publicly reachable | "" |
sso.tokenEndpoint, .userinfoEndpoint |
Called by the pod; may be in-cluster | "" |
sso.logoutUrl |
"" |
|
sso.clientSecret.value |
Generated on first install when empty | "" |
sso.clientSecret.existingSecret.name |
Use a Secret you manage | "" |
sso.keycloakBootstrap.enabled |
Configure the Keycloak side automatically | false |
sso.keycloakBootstrap.url |
Admin API base URL, reachable in-cluster | "" |
sso.keycloakBootstrap.realm |
Must already exist; the Job creates no realms | "" |
sso.keycloakBootstrap.admin.existingSecret.name |
Required. Keycloak administrator | "" |
sso.keycloakBootstrap.admin.realm |
Realm holding the admin account | master |
sso.keycloakBootstrap.roles.admin, .user |
Must be names LiteLLM recognises | proxy_admin, internal_user |
sso.keycloakBootstrap.groups.admin, .user |
Groups granted those roles | litellm-admins, litellm-users |
sso.keycloakBootstrap.adminUser.enabled |
Create a named administrator account | true |
sso.keycloakBootstrap.adminUser.username |
llmadmin |
|
sso.keycloakBootstrap.adminUser.password.value |
Generated when empty | "" |
sso.keycloakBootstrap.adminUser.permanentPassword |
False forces a change at first sign-in | true |
sso.keycloakBootstrap.extraRedirectUris |
Beyond the two the chart registers | [] |
sso.keycloakBootstrap.image.* |
Needs kcadm.sh; match the server’s minor version |
quay.io/keycloak/keycloak:26.3.2 |
sso.keycloakBootstrap.waitTimeout |
Seconds to wait for Keycloak | 300 |
sso.keycloakBootstrap.hooks.helm.weight |
Runs after the database Jobs | "2" |
Database
| Key | Description | Default |
|---|---|---|
database.enabled |
Forced on by modelManagement.mode: database |
false |
database.host, .port, .name, .schema |
"", 5432, litellm, "" |
|
database.sslMode |
verify-full for a managed service |
"" |
database.sslRootCert |
In-container path to a CA bundle | "" |
database.auth.mode |
password, awsIam, or azureEntra |
password |
database.existingSecret.name |
Required; no inline password is accepted | "" |
database.existingSecret.usernameKey, .passwordKey |
username, password |
|
database.existingSecret.urlKey |
Whole URL from one key | "" |
database.existingSecret.hostKey |
Host from a Secret key | "" |
database.readReplica.enabled |
Route read-only queries to a replica | false |
database.readReplica.host, .port |
Empty falls back to the writer | "" |
database.connectionPool.enabled |
In-pod PgBouncer, transaction mode | false |
database.connectionPool.maxDbConnections |
Upstream cap per pod | 20 |
database.connectionPool.maxClientConn |
1000 |
|
database.disablePreparedStatements |
Required in front of an external pooler | false |
database.maxIdleConnectionLifetime |
Seconds; the proxy defaults to 60 | "" |
Creating the database
| Key | Description | Default |
|---|---|---|
database.init.enabled |
Create the database, role, and schema before anything else runs | false |
database.init.admin.username |
Administrator login. Recorded in the Helm release secret | "" |
database.init.admin.password |
Administrator password. Recorded in the Helm release secret | "" |
database.init.admin.existingSecret.name |
Read the admin credentials from a Secret you own. The chart then erases nothing | "" |
database.init.admin.existingSecret.usernameKey, .passwordKey |
username, password |
|
database.init.admin.database |
Maintenance database to connect to for CREATE DATABASE |
postgres |
database.init.admin.sslMode |
Falls back to database.sslMode |
"" |
database.init.createDatabase |
true |
|
database.init.createUser |
Create the login role, and reset its password to match the Secret | true |
database.init.createSchema |
Needs a non-empty database.schema |
true |
database.init.cleanup |
Erase the admin credentials from the Secret the chart created | true |
database.init.waitTimeout |
Seconds to wait for the server to accept connections | 300 |
database.init.image.* |
Needs psql and pg_isready |
postgres:17-alpine |
database.init.kubectlImage.* |
Cleanup container only | registry.k8s.io/kubectl:v1.34.0 |
database.init.serviceAccount.create |
ServiceAccount, Role, and RoleBinding for the cleanup container | true |
database.init.hooks.helm.weight |
Must be below migrationJob.hooks.helm.weight. Also used as the ArgoCD sync wave |
"-5" |
database.init.hooks.argocd.enabled |
false |
|
database.init.backoffLimit, .ttlSecondsAfterFinished, .activeDeadlineSeconds |
4, 120, 900 |
|
database.init.resources, .nodeSelector, .tolerations, .affinity |
{} |
|
database.init.podSecurityContext, .securityContext |
{} |
Redis
| Key | Description | Default |
|---|---|---|
redis.host, .port |
"", 6379 |
|
redis.existingSecret.name, .passwordKey |
"", password |
|
redis.sentinel.enabled, .masterSet |
false, mymaster |
|
redis.coordination |
Render general_settings.coordination_redis |
true |
valkey.enabled |
Deploy a Valkey and wire redis.* to it |
false |
valkey.* |
Passed through to the valkey subchart | its defaults |
Response cache
| Key | Description | Default |
|---|---|---|
cache.enabled |
Needs a Redis: redis.host or valkey.enabled |
false |
cache.type |
redis only; write cache_params yourself for anything else |
redis |
cache.ttl |
Seconds an entry stays valid; empty leaves LiteLLM’s default | "" |
cache.namespace |
Key prefix, so releases can share one Redis | "" |
cache.supportedCallTypes |
Call types to cache; empty caches all of them | [] |
Langfuse
| Key | Description | Default |
|---|---|---|
langfuse.enabled |
Wire this proxy to a Langfuse | false |
langfuse.host |
Base URL including the scheme; empty uses the bundled server | "" |
langfuse.existingSecret.name |
Project keys; required, in both modes | "" |
langfuse.existingSecret.publicKeyKey |
Key holding pk-lf-… |
public-key |
langfuse.existingSecret.secretKeyKey |
Key holding sk-lf-… |
secret-key |
langfuse.deploy.enabled |
Also deploy Langfuse; see the footprint warning | false |
langfuse.* |
Passed through to the langfuse subchart | its defaults |
Migration Job
| Key | Description | Default |
|---|---|---|
migrationJob.enabled |
true |
|
migrationJob.hooks.helm.enabled |
Required for repeated upgrades | true |
migrationJob.hooks.helm.weight |
"1" |
|
migrationJob.hooks.argocd.enabled |
Turn on only with the Helm hook off | false |
migrationJob.backoffLimit |
4 |
|
migrationJob.activeDeadlineSeconds |
Whole-Job budget; null to disable |
1800 |
migrationJob.ttlSecondsAfterFinished |
120 |
|
migrationJob.serviceAccountName |
For a Job-specific cloud identity | "" |
migrationJob.resources |
{} |
|
migrationJob.extraContainers, .extraInitContainers |
[] |
Scaling
| Key | Description | Default |
|---|---|---|
autoscaling.enabled |
false |
|
autoscaling.minReplicas, .maxReplicas |
maxReplicas sets the DB connection budget |
1, 100 |
autoscaling.targetCPUUtilizationPercentage |
60 |
|
autoscaling.targetMemoryUtilizationPercentage |
Deliberately unset; see above | "" |
autoscaling.targetRequestsPerSecond, .targetTokensPerSecond |
Need a Prometheus Adapter | "" |
autoscaling.behavior |
{} |
|
pdb.enabled |
false |
|
pdb.minAvailable, .maxUnavailable |
Set exactly one | null |
extraResources |
Extra manifests, run through tpl |
[] |
Validation
The chart fails the render, naming the value, rather than producing a workload that cannot start. A template-time error is readable; the same mistake found at runtime is an opaque CrashLoopBackOff or a route that silently publishes nothing.
| Condition | Why it fails |
|---|---|
config mode with no models |
The proxy needs at least one model to start |
seedFromConfig in config mode |
Every model is already in config.yaml; the value would do nothing |
| Database flow with no credentials Secret | Credentials come from a Secret only |
Unknown database.auth.mode |
Caught by the schema before templates render |
httpRoute with no parentRefs |
A route with no parent attaches to no Gateway |
ingress or httpProxy with no hosts |
Would publish nothing |
| TLS enabled with no certificate Secret | Would serve the controller’s default certificate |
More than one host on httpProxy or route |
Both carry exactly one |
metricsServer.port equal to service.port |
The container cannot bind both |
image.tag of latest or main-latest |
Splits the proxy and the migration Job across versions |
podLabels setting a selector key |
The Deployment selector is immutable; the apply would be rejected |
pdb with neither or both bounds |
Ambiguous |
autoscaling.minReplicas above maxReplicas |
Cannot be satisfied |
database.init with no admin credentials, or with two sources for them |
Nothing to authenticate as, or ambiguity about which gets erased |
database.init under awsIam or azureEntra |
The cloud issues the login, so there is no password to set on a role |
database.init with existingSecret.urlKey |
A connection URL carries no separate user to create |
database.init.createSchema with an empty database.schema |
Nothing to create |
database.init hook weight at or above the migration’s |
The migration would run before the database exists |
serverRootPath with routing.path: / |
The release would still claim the whole host |
serverRootPath and routing.path disagreeing |
The published prefix would return 404 |
sso.enabled with no host and no proxyBaseUrl |
There is no redirect URI to register |
sso.proxyBaseUrl without a scheme |
Providers reject such a redirect_uri |
keycloakBootstrap with no admin Secret |
Nothing to authenticate to Keycloak as |
keycloakBootstrap roles outside LiteLLM’s own set |
The role is ignored and users get no privileges |
keycloakBootstrap admin and user groups the same |
One group cannot carry two roles |
keycloakBootstrap without sso.enabled |
It would register a client nothing uses |
cache.enabled with no Redis |
The proxy would log connection errors and bill every request |
cache.type other than redis |
Caught by the schema; the chart wires no other backend |
redis.sentinel.enabled with valkey.enabled |
The bundled Valkey has no Sentinel in front of it |
valkey.auth.enabled with no readable password |
The proxy would connect unauthenticated and fail with NOAUTH |
langfuse.enabled with no Secret |
Project keys come from a Secret only |
langfuse.enabled with no host and no bundled server |
There is nowhere to send traces |
langfuse.host without a scheme |
The SDK uses it as a base URL verbatim |
langfuse.deploy.enabled without langfuse.enabled |
It would deploy the whole platform and send it nothing |
Upgrading
helm diff upgrade litellm helm-charts/litellm -n litellm -f my-values.yaml
helm upgrade litellm helm-charts/litellm -n litellm -f my-values.yaml
The master key is never rotated by an upgrade, so virtual keys survive. In the database flow the migration Job runs first, as a pre-upgrade hook, and the release waits on it.
To move to a new LiteLLM release, bump appVersion in Chart.yaml rather than setting image.tag. That keeps the proxy and the migration Job on one version by construction.
Read the upstream release notes before crossing a minor version. Migrations are one-way, and rolling the chart back does not roll the schema back.
Troubleshooting
Pods crash-loop straight after install. Check kubectl -n litellm logs deploy/litellm. A bad proxyConfig shows as a startup parse error naming the key. In the database flow, check the migration Job first: kubectl -n litellm logs job/litellm-migrations.
helm upgrade hangs. Usually the migration Job waiting on a database lock. migrationJob.activeDeadlineSeconds bounds this at 1800 seconds by default; if it was set to null, the release waits forever.
Rate limits allow more than configured. redis.host is unset and each pod is counting on its own. See Redis.
Streaming responses are truncated. A timeout at the routing layer. See the timeout guidance for your implementation.
The Admin UI shows no models in database mode. The catalog is in the database, not in config.yaml. An empty list is expected on a fresh install; add models through the UI or POST /model/new.
Model changes do not take effect in config mode. Confirm the ConfigMap changed and the pods rolled. The pod template carries checksum/config, so a real change always restarts the pods; no restart means the rendered config was identical.
A routing object exists but nothing serves. Check the controller accepted it:
kubectl -n litellm get httproute litellm -o jsonpath='{.status.parents[*].conditions[?(@.type=="Accepted")]}'
kubectl -n litellm get httpproxy litellm -o jsonpath='{.status.currentStatus}'
kubectl -n litellm describe ingress litellm
Development
helm dependency build helm-charts/litellm
helm lint helm-charts/litellm --strict
helm unittest helm-charts/litellm
bash helm-charts/litellm/ci/validate.sh
helm package helm-charts/litellm --destination /tmp
helm dependency build comes first and is not optional: Helm refuses to render a chart whose declared dependencies are missing from charts/, even when every condition is false. It installs the versions in Chart.lock; use helm dependency update only when you mean to move a pin, and commit the rewritten lock with it — CI fails a run whose Chart.lock changes.
tests/ holds per-template assertions. ci/validate.sh holds whole-release render checks, where the question is which objects exist together: both flows, every routing implementation, and that the proxy and the migration Job agree on one image. .github/workflows/helm-charts.yml runs all of it for every chart under helm-charts/, discovering them by listing the directory.
Pass
--kube-versionto anyhelm templateyou run by hand. Helm 3 and Helm 4 assume different Kubernetes versions when no cluster is reachable, which changes whether this chart’skubeVersionconstraint is met.
For schema validation:
helm template litellm helm-charts/litellm -n litellm --kube-version 1.31.0 | \
helm kubeconform --summary --ignore-missing-schemas -f - helm-charts/litellm
The three CRD-based routing objects are skipped unless you pass --schema-location for their schemas.
Not included
- A bundled PostgreSQL. See Prerequisites.
database.initcreates a database on a server you run; it does not run the server. A Redis is different:valkey.enabledcan deploy one, because a response cache holds nothing that is not reconstructible. - The split gateway / backend / UI deployment from the upstream
helm/litellmchart. One Deployment keeps the baseline at one pod and avoids path-prefix dispatch that has to track the image’s route allowlist. The templates carryapp.kubernetes.io/componentlabels and per-component helpers, so the split can be added later without breaking any existing value path. - KEDA autoscaling. The HPA covers CPU and the two per-pod workload metrics.
- Enterprise billable-request metering.