This commit is contained in:
sanjibani 2026-08-27 15:26:36 -06:00 committed by GitHub
commit 327a3aab17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2414 additions and 3 deletions

View file

@ -10,6 +10,7 @@
<a href="https://railway.com/deploy/RhvhdC?referralCode=7mRv9K&utm_medium=integration&utm_source=template&utm_campaign=generic"><img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40"></a>
<a href="https://console.aws.amazon.com/cloudshell/home" target="_blank" rel="nofollow"><img src="./.github/deploy-on-aws.png" alt="Deploy on AWS" height="40"></a>
<a href="https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Flitellm&cloudshell_workspace=terraform%2Flitellm%2Fgcp%2Fexamples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true" target="_blank" rel="nofollow"><img src="./.github/deploy-on-gcp.png" alt="Deploy on GCP" height="40"></a>
<a href="https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2FBerriAI%2Flitellm%2Fmain%2Fterraform%2Flitellm%2Fazure%2Fexamples%2Fdefault%2Fazuredeploy.json" target="_blank" rel="nofollow"><img src="./.github/deploy-on-azure.png" alt="Deploy on Azure" height="40"></a>
</p>
</p>
<h4 align="center"><a href="https://docs.litellm.ai/docs/simple_proxy" target="_blank">LiteLLM Proxy Server (AI Gateway)</a> | <a href="https://docs.litellm.ai/docs/enterprise#hosted-litellm-proxy" target="_blank"> Hosted Proxy</a> | <a href="https://litellm.ai/enterprise"target="_blank">Enterprise Tier</a> | <a href="https://www.litellm.ai/ai-gateway" target="_blank">Website</a></h4>
@ -536,16 +537,71 @@ terraform apply
Provider API keys live in Secret Manager; reference resource IDs (e.g. `projects/my-gcp-project/secrets/openai-api-key`) via `gateway_extra_secrets`. Full input list and architecture diagram on the [registry page](https://registry.terraform.io/modules/BerriAI/litellm/google/latest?tab=inputs).
#### Azure: Container Apps + App Gateway
Container Apps + Application Gateway with path-based routing, matching the AWS / GCP stacks. Pulls the four componentized images directly from GHCR (no mirror needed for Azure).
```bash
git clone https://github.com/BerriAI/litellm.git
cd litellm/terraform/litellm/azure/examples/default
cp terraform.tfvars.example terraform.tfvars # edit location/tenant/env
terraform init && terraform apply
```
[Module page →](https://registry.terraform.io/modules/BerriAI/litellm/azure/latest)
To call the module from your own root config:
```hcl
# main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 3.117" }
}
}
provider "azurerm" {
features {}
}
module "litellm" {
source = "BerriAI/litellm/azure"
version = "~> 1.89"
location = "eastus"
tenant = "acme"
env = "prod"
azs = ["1", "2"]
# Production: upload a TLS cert to Key Vault and reference it here.
# Without one, set allow_plaintext_app_gateway = true (dev/trial only).
# key_vault_certificate_id = "https://acmekv.vault.azure.net/certificates/litellm-cert/<version>"
allow_plaintext_app_gateway = true
}
output "litellm_url" {
value = module.litellm.app_gateway_url
}
```
```bash
terraform init
terraform apply
```
Provider API keys live in Key Vault; reference secret IDs via `gateway_extra_secrets`. Full input list on the [module README](./terraform/litellm/azure/README.md).
#### Both stacks include
- The full componentized split (gateway / backend / UI as independent services)
- Managed Postgres (writer + reader) and Redis
- Managed Postgres and Redis
- Versioned object store for proxy state + file uploads
- An auto-generated `LITELLM_MASTER_KEY` in your cloud's secret manager
- A one-off migration job that runs `prisma migrate deploy` before the proxy starts
- The same `proxy_config` surface as the [Helm chart](./helm/litellm/) — pass YAML as a typed map
- The same `proxy_config` surface as the [Helm chart](./helm/litellm/), pass YAML as a typed map
The Terraform modules live at [`terraform/litellm/aws/`](./terraform/litellm/aws/) and [`terraform/litellm/gcp/`](./terraform/litellm/gcp/) in this repo; the registry entries are read-only mirrors updated on each release.
The Terraform modules live at [`terraform/litellm/aws/`](./terraform/litellm/aws/), [`terraform/litellm/gcp/`](./terraform/litellm/gcp/), and [`terraform/litellm/azure/`](./terraform/litellm/azure/) in this repo; the registry entries are read-only mirrors updated on each release.
### Run in Developer Mode
#### Services

View file

@ -0,0 +1,219 @@
# LiteLLM on Azure (Container Apps + App Gateway)
Deploys the componentized LiteLLM proxy on Azure:
- **VNet** with three isolated subnets: containers (delegated to Azure
Container Apps), private endpoints (Postgres / Redis / Storage / Key
Vault), Application Gateway
- **Azure Database for PostgreSQL Flexible Server**, single instance,
with Entra (Azure AD) authentication enabled. Password auth is also
enabled for bootstrap
- **Azure Cache for Redis** (Basic by default; Standard / Premium
recommended for production)
- **Storage Account** (private, versioned, TLS 1.2 minimum) with a
`proxy` blob container for cache / proxy_config + a `files` blob
container for /v1/files passthrough storage
- **Key Vault** holding `LITELLM_MASTER_KEY`, the optional license, the
optional UI password, and the bootstrap Postgres admin password
- **Container Apps Environment** running three apps (`gateway`, port
4000; `backend`, port 4001; `ui`, port 3000) plus a one-off
`migrations` Container Apps Job that runs `prisma migrate deploy`
from the dedicated `ghcr.io/berriai/litellm-migrations` image
- **User-assigned managed identity** shared across the three apps + the
migrations job, granted: Storage Blob Data Contributor on the storage
account, Key Vault Secrets User on the vault
- **Application Gateway v2** with path-based routing:
- LLM data-plane prefixes (`/v1/chat/*`, `/v1/embeddings`, ...) ->
gateway
- UI asset paths (`/_next/*`, `/assets/*`, ...) -> ui
- Everything else (management API: `/key/*`, `/user/*`, ...) ->
backend
## Quick start
```bash
cd terraform/litellm/azure/examples/default
cp terraform.tfvars.example terraform.tfvars # edit location/tenant/env
terraform init && terraform apply
```
After the apply:
1. As the Entra admin (matches the principal running terraform), run
the `db_bootstrap_sql` output once against the Postgres Flexible
Server. This creates the `var.db_username` user the proxy will
authenticate as via Entra-managed-identity tokens at runtime.
2. Run the `migration_run_command` output to trigger the one-off
prisma migration Container App Job. The gateway / backend revisions
do not auto-redeploy after this; that's intentional so traffic is
not cut over mid-migration.
3. Point DNS at `app_gateway_fqdn` output. For TLS, set
`key_vault_certificate_id` after the initial apply and re-apply; the
HTTPS listener picks up the cert automatically.
## Components
### `proxy_config` (preferred)
Mirrors the helm chart's `gateway.config.proxy_config`. The map is
YAML-encoded and uploaded to the storage account blob
`config/litellm-config.yaml`; the gateway and backend container
start scripts download it to `/tmp/litellm-config.yaml` and set
`CONFIG_FILE_PATH` to match. Editing the value produces a new blob
content and a rolling redeploy of both services.
```hcl
proxy_config = {
model_list = [
{
model_name = "gpt-4o"
litellm_params = {
model = "openai/gpt-4o"
api_key = "os.environ/OPENAI_API_KEY"
}
},
]
general_settings = {
master_key = "os.environ/LITELLM_MASTER_KEY"
database_url = "os.environ/DATABASE_URL"
}
}
```
LiteLLM resolves `os.environ/<NAME>` references in the YAML against
the container's environment. That means provider API keys belong in
`*_extra_secrets` (next section), and your YAML just references them
by name.
### Extra env vars
Non-sensitive plaintext (feature flags, observability hosts, etc.) via
`gateway_extra_env` / `backend_extra_env`:
```hcl
gateway_extra_env = {
LITELLM_LOG = "INFO"
LANGFUSE_PUBLIC_KEY = "pk-lf-..."
}
backend_extra_env = {
UI_USERNAME = "admin"
}
```
### Extra secrets
Sensitive env vars (provider API keys) via `gateway_extra_secrets` /
`backend_extra_secrets`:
```hcl
gateway_extra_secrets = {
OPENAI_API_KEY = azurerm_key_vault_secret.openai.id
}
```
The Container Apps managed identity has `Get` / `List` on the Key Vault,
so it can read the referenced secret URIs. Create those `*_secret`
resources separately and grant the same access policy.
## Database authentication
The Container Apps managed identity uses Entra (Azure AD) tokens at
runtime against the Postgres Flexible Server. The proxy assembles
`DATABASE_URL` from `DATABASE_HOST/PORT/USER/NAME` + a short-lived
AAD token minted via `azure-identity`'s `DefaultAzureCredential`. The
gateway / backend / migration Container Apps have
`DATABASE_AZURE_AUTH=true` in their environment.
**Break-glass.** The local `litellm_admin` user (password lives in
Key Vault as `db-admin-password`) is kept for break-glass repairs. To
rotate, use the Azure Portal -> "Reset password" or pass a new
`random_password.db_admin_password` resource via a maintenance PR.
**Prerequisite.** A first-time `terraform apply` requires either
`var.litellm_master_key != ""` (the secret is set by the caller) OR
that the current terraform principal is granted `Set` / `Get` on the
Key Vault (the access policy is created automatically by
`keyvault.tf`).
## Container Apps pull
By default the stack pulls the four component images from
`ghcr.io/berriai/litellm-{gateway,backend,ui,migrations}:<tag>`.
Azure Container Apps can pull from `ghcr.io` directly without mirror
configuration (the AWS stack pulls from ECR post-push, the GCP stack
needs an Artifact Registry remote repo, Azure is the closest to a
direct pull-from-public-registry experience).
If you want to pin to a private Azure Container Registry (ACR) instead,
push the images there first and override the `image_*` variables:
```hcl
image_registry = "myregistry.azurecr.io"
image_namespace = "litellm"
image_tag = "v1.86.0"
```
The same role assignment (`AcrPull` for the Container Apps managed
identity) is granted automatically at apply time when the registry is
an `*.azurecr.io` host.
## Observability
Set `otel_endpoint` to an OTLP collector URL to turn on OpenTelemetry
v2 instrumentation in both gateway and backend. Service names are
stamped per component:
```
${local.name}-gateway
${local.name}-backend
```
The current terraform principal can also grant `Monitoring Metrics
Publisher` on the same resource group if you want Container Apps
metrics to feed to Application Insights.
## Component sizing defaults
Defaults work for a small team / production-trail pattern:
| Component | CPU | Memory | Min replicas | Max replicas |
|-----------|-----|--------|--------------|--------------|
| gateway | 1.0 | 2Gi | 2 | 10 |
| backend | 1.0 | 2Gi | 1 | 5 |
| ui | 0.5 | 1Gi | 1 | 3 |
| migrations| 1.0 | 2Gi | (job) | (job) |
Bump these via `gateway_cpu`, `gateway_memory`, etc. The full input list
is on the (forthcoming) Terraform Registry page; until then, see
`variables.tf` in this directory.
## State and CI
Apply from CI? Then pin the constructor's
`role_definition_name` to `Owner` or `Contributor + User Access
Administrator` on the subscription so the role assignments and
Key Vault access policies land. The current terraform principal's
object ID is read via `data.azurerm_client_config`; CI runners need to
be authenticated as the AAD admin for the bootstrap pattern to work
without manual steps.
## Known gaps vs AWS / GCP
- Azure Container Apps does not natively integrate with managed
Postgres IAM tokens the way Aurora + RDS-iam-token does on AWS.
Entra-managed-identity tokens at the application layer are the
equivalent. The `proxy_config` blob is the only state shared
between the two write paths; everything else is per-component env.
- Application Gateway WAF v2 is not enabled in this baseline. To turn
it on, swap the SKU `Standard_v2` to `WAF_v2` and add WAF policy
resources. The path rule structure does not change.
- Redis SSL on Azure: the `rediss://` URL is built from the SSL port
(default 6380). The proxy uses `REDIS_SSL=true` to flip the scheme,
matching AWS module's `transit_encryption_enabled = true`.
- Migration ordering: the AWS stack uses `depends_on` chains via
ECS task ordering; Azure Container Apps Jobs are not part of ACA
service ordering. The migration is run as a manual one-shot AFTER
the gateway / backend services are deployed. The README's
Quick-start ordering covers this.

View file

@ -0,0 +1,315 @@
# Container Apps Environment (one per stack) + the three componentized
# apps (gateway / backend / ui) plus the migrations job.
#
# The Azure equivalent of AWS ECS Fargate is Azure Container Apps: a
# serverless managed-Kubernetes-ish runtime that runs containers, has
# built-in HTTPS ingress with cert-managed-by-Azure, autoscaling, and
# native Azure AD / managed-identity support. The ACA infrastructure
# subnet must be /23 or larger; we already carved a /20 in network.tf.
# ---------- Shared environment ----------
#
# Observability: when var.otel_endpoint is set, OTel instrumentation
# turns on for both gateway and backend. Service names match the AWS
# stack so spans land in the right bucket.
locals {
otel_enabled = var.otel_endpoint != ""
otel_environment_name = var.otel_environment_name != "" ? var.otel_environment_name : var.env
otel_shared_env_raw = local.otel_enabled ? [
{ name = "LITELLM_OTEL_V2", value = "true" },
{ name = "OTEL_EXPORTER", value = var.otel_exporter },
{ name = "OTEL_ENDPOINT", value = var.otel_endpoint },
{ name = "OTEL_ENVIRONMENT_NAME", value = local.otel_environment_name },
{ name = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", value = var.otel_capture_message_content },
] : []
gateway_otel_env = [
for e in local.otel_shared_env_raw : e if !contains(keys(var.gateway_extra_env), e.name)
]
backend_otel_env = [
for e in local.otel_shared_env_raw : e if !contains(keys(var.backend_extra_env), e.name)
]
# Shared Postgres + Redis + Storage env, fed to gateway / backend /
# migrations. Matches the AWS module's `shared_env` shape for those
# values that map to Azure equivalents.
shared_pg_env = [
{ name = "DATABASE_HOST", value = azurerm_postgresql_flexible_server.this.fqdn },
{ name = "DATABASE_PORT", value = "5432" },
{ name = "DATABASE_USER", value = var.db_username },
{ name = "DATABASE_NAME", value = var.db_name },
# Azure uses Entra-managed-identity token auth at runtime
# (azure-identity's DefaultAzureCredential). The proxy's
# init_azure_db_url_from_env equivalent mints a short-lived token and
# assembles DATABASE_URL when DATABASE_AZURE_AUTH=true is set.
{ name = "DATABASE_AZURE_AUTH", value = "true" },
]
shared_redis_env = [
{ name = "REDIS_HOST", value = azurerm_redis_cache.this.hostname },
{ name = "REDIS_PORT", value = tostring(azurerm_redis_cache.this.ssl_port) },
{ name = "REDIS_SSL", value = "true" },
]
shared_storage_env = [
# LiteLLM reads AZURE_STORAGE_ACCOUNT_NAME and the AZURE_BLOB_*
# container env vars. Auth is via the Container Apps managed identity
# (Storage Blob Data Contributor role, see iam.tf).
{ name = "AZURE_STORAGE_ACCOUNT_NAME", value = azurerm_storage_account.this.name },
{ name = "AZURE_BLOB_STORAGE_ACCOUNT_NAME", value = azurerm_storage_account.this.name },
{ name = "AZURE_BLOB_STORAGE_CONTAINER_NAME", value = azurerm_storage_container.proxy.name },
{ name = "AZURE_FILE_STORAGE_CONTAINER_NAME", value = azurerm_storage_container.files.name },
# CONFIG_FILE_PATH is read by the LiteLLM entrypoint to know where
# to download the uploaded proxy_config.yaml blob to.
{ name = "CONFIG_FILE_PATH", value = "/tmp/litellm-config.yaml" },
]
# Combine env plus extra_env (caller override) for each component.
gateway_base_env = concat(
local.shared_pg_env,
local.shared_redis_env,
local.shared_storage_env,
local.gateway_otel_env,
)
backend_base_env = concat(
local.shared_pg_env,
local.shared_redis_env,
local.shared_storage_env,
local.backend_otel_env,
)
migrations_base_env = concat(
local.shared_pg_env,
local.shared_redis_env,
)
# Reference secret IDs (Key Vault secret URIs) for env vars that come
# from Key Vault instead of plaintext. Each entry becomes a valueFrom
# in the Container App env block.
gateway_kv_secret_env = merge(
{
LITELLM_MASTER_KEY = azurerm_key_vault_secret.master_key.id
},
var.litellm_license != "" ? { LITELLM_LICENSE = one(azurerm_key_vault_secret.license[*].id) } : {},
var.gateway_extra_secrets,
)
backend_kv_secret_env = merge(
{
LITELLM_MASTER_KEY = azurerm_key_vault_secret.master_key.id,
},
var.ui_password != "" ? { UI_PASSWORD = one(azurerm_key_vault_secret.ui_password[*].id) } : {},
var.litellm_license != "" ? { LITELLM_LICENSE = one(azurerm_key_vault_secret.license[*].id) } : {},
var.backend_extra_secrets,
)
migrations_kv_secret_env = {
LITELLM_MASTER_KEY = azurerm_key_vault_secret.master_key.id
}
}
# ---------- Container Apps Environment ----------
resource "azurerm_container_app_environment" "this" {
name = "${local.name}-cae"
location = var.location
resource_group_name = local.resource_group_name
tags = local.tags
infrastructure_subnet_id = azurerm_subnet.containers.id
depends_on = [azurerm_role_assignment.container_apps_env_infra]
}
# ---------- Log Analytics workspace ----------
#
# Container Apps stdout/stderr route to Log Analytics. The default
# workspace created here is per-stack; callers that want to centralize
# logs can pass `log_analytics_workspace_id` (see variables) but that's
# out of scope for the baseline.
resource "azurerm_log_analytics_workspace" "this" {
name = "${local.name}-logs"
location = var.location
resource_group_name = local.resource_group_name
sku = "PerGB2018"
retention_in_days = var.log_retention_days
tags = local.tags
}
# ---------- gateway ----------
resource "azurerm_container_app" "gateway" {
name = "${local.name}-gateway"
container_app_environment_id = azurerm_container_app_environment.this.id
resource_group_name = local.resource_group_name
revision_mode = "Single"
tags = local.tags
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.container_apps.id]
}
# The gateway is internal-only; the Application Gateway front ends it
# and fronts the UI; the backend is reachable from gateway + ui only.
ingress {
allow_insecure_connections = false
external_enabled = false
target_port = 4000
transport = "auto"
traffic_weight {
latest_revision = true
percentage = 100
}
}
registry {
server = var.image_registry
}
template {
min_replicas = var.gateway_min_replicas
max_replicas = var.gateway_max_replicas
container {
name = "gateway"
image = local.gateway_image_resolved
cpu = var.gateway_cpu
memory = var.gateway_memory
dynamic "env" {
for_each = concat(
local.gateway_base_env,
[for k, v in var.gateway_extra_env : { name = k, value = v }],
)
content {
name = env.value.name
value = env.value.value
}
}
# Key Vault references resolved at startup using the managed identity.
dynamic "env" {
for_each = { for k, v in local.gateway_kv_secret_env : k => v }
content {
name = env.key
secret_value = env.value
}
}
}
}
depends_on = [
azurerm_key_vault_access_policy.container_apps,
]
}
# ---------- backend ----------
resource "azurerm_container_app" "backend" {
name = "${local.name}-backend"
container_app_environment_id = azurerm_container_app_environment.this.id
resource_group_name = local.resource_group_name
revision_mode = "Single"
tags = local.tags
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.container_apps.id]
}
ingress {
allow_insecure_connections = false
external_enabled = false
target_port = 4001
transport = "auto"
traffic_weight {
latest_revision = true
percentage = 100
}
}
registry {
server = var.image_registry
}
template {
min_replicas = var.backend_min_replicas
max_replicas = var.backend_max_replicas
container {
name = "backend"
image = local.backend_image_resolved
cpu = var.backend_cpu
memory = var.backend_memory
dynamic "env" {
for_each = concat(
local.backend_base_env,
[for k, v in var.backend_extra_env : { name = k, value = v }],
)
content {
name = env.value.name
value = env.value.value
}
}
dynamic "env" {
for_each = { for k, v in local.backend_kv_secret_env : k => v }
content {
name = env.key
secret_value = env.value
}
}
}
}
}
# ---------- ui ----------
resource "azurerm_container_app" "ui" {
name = "${local.name}-ui"
container_app_environment_id = azurerm_container_app_environment.this.id
resource_group_name = local.resource_group_name
revision_mode = "Single"
tags = local.tags
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.container_apps.id]
}
ingress {
allow_insecure_connections = false
external_enabled = false
target_port = 3000
transport = "auto"
traffic_weight {
latest_revision = true
percentage = 100
}
}
registry {
server = var.image_registry
}
template {
min_replicas = var.ui_min_replicas
max_replicas = var.ui_max_replicas
container {
name = "ui"
image = local.ui_image_resolved
cpu = var.ui_cpu
memory = var.ui_memory
dynamic "env" {
for_each = var.ui_extra_env
content {
name = env.key
value = env.value
}
}
}
}
}

View file

@ -0,0 +1,43 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/azurerm" {
version = "3.117.1"
constraints = "~> 3.117"
hashes = [
"h1:j6wnjpHfBcQC4xd3ZYquaIPIIR46xJQs7rxwPdSOZos=",
"zh:0c513676836e3c50d004ece7d2624a8aff6faac14b833b96feeac2e4bc2c1c12",
"zh:50ea01ada95bae2f187db9e926e463f45d860767a85ebc59160414e00e76c35d",
"zh:52c2a9edacc06b3f72153f5ef6daca0761c6292158815961fe37f60bc576a3d7",
"zh:618eed2a06b19b1a025b45b05891846d570a6a1cca4d23f4942f5a99e1f747ae",
"zh:61cde5d3165d7e5ec311d5d89486819cd605c1b2d54611b5c97bd4e97dba2762",
"zh:6a873358d5031fc222f5e05f029d1237f3dce8345c767665f393283dfa2627f6",
"zh:afdd80064b2a04da311856feb4ed45f77ff4df6c356e8c2b10afb51fe7e61c70",
"zh:b09113df7e0e8c8959539bd22bae6c39faeb269ba3c4cd948e742f5cf58c35fb",
"zh:d340db7973109761cfc27d52aa02560363337c908b2c99b3628adc5a70a99d5b",
"zh:d5a577226ebc8c65e8f19384878a86acc4b51ede4b4a82d37c3b331b0efcd4a7",
"zh:e2962b147f9e71732df8dbc74940c10d20906f3c003cbfaa1eb9fabbf601a9f0",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}
provider "registry.terraform.io/hashicorp/random" {
version = "3.9.0"
constraints = "~> 3.6"
hashes = [
"h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=",
"zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1",
"zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea",
"zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f",
"zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0",
"zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61",
"zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc",
"zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e",
"zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef",
"zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b",
"zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257",
"zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04",
]
}

View file

@ -0,0 +1,43 @@
# One-command deploy of the LiteLLM Azure stack.
#
# cd terraform/litellm/azure/examples/default
# cp terraform.tfvars.example terraform.tfvars # edit it
# terraform init
# terraform apply
#
# This root just wires the provider (see providers.tf) to the module. The
# module itself (../../) declares no provider, so it can also be consumed
# from your own config with count/for_each/aliased providers:
#
# module "litellm" {
# source = "github.com/BerriAI/litellm//terraform/litellm/azure?ref=<tag>"
# ...
# }
#
# Knobs not surfaced as variables here (per-component sizing, autoscaling,
# Postgres / Redis / Storage tuning) can be set directly on this block:
# see ../../variables.tf.
module "litellm" {
source = "../../"
location = var.location
tenant = var.tenant
env = var.env
azs = var.azs
litellm_master_key = var.litellm_master_key
litellm_license = var.litellm_license
ui_password = var.ui_password
key_vault_certificate_id = var.key_vault_certificate_id
allow_plaintext_app_gateway = var.allow_plaintext_app_gateway
storage_force_destroy = var.storage_force_destroy
proxy_config = var.proxy_config
gateway_extra_env = {}
backend_extra_env = {}
gateway_extra_secrets = {}
backend_extra_secrets = {}
}

View file

@ -0,0 +1,8 @@
provider "azurerm" {
features {}
# Default: subscription_id is read from the environment (`ARM_SUBSCRIPTION_ID`).
# Explicit override: uncomment and set the subscription_id below.
# subscription_id = "00000000-0000-0000-0000-000000000000"
}
provider "random" {}

View file

@ -0,0 +1,42 @@
location = "eastus"
tenant = "acme"
env = "stage"
azs = ["1", "2"]
# Optional: pre-existing LiteLLM master key (must begin with `sk-`). Leave
# empty to have the stack auto-generate a random `sk-...` value and store
# it in Key Vault.
# litellm_master_key = "sk-..."
# Optional: enterprise license (omit for OSS-only).
# litellm_license = "..."
# Optional: UI admin password (omit to fall back to LITELLM_MASTER_KEY).
# ui_password = "..."
# Optional: Key Vault certificate ID for the App Gateway HTTPS listener.
# Either set this OR set allow_plaintext_app_gateway = true (dev only).
# key_vault_certificate_id = "https://acmestagekv.vault.azure.net/certificates/litellm-cert/<version>"
allow_plaintext_app_gateway = true
# Optional: tell `terraform destroy` to remove a non-empty storage
# container (and any uploaded files). Off by default.
storage_force_destroy = false
# Optional: enable proxy config with model list. Triggers a Container App
# revision swap when changed.
proxy_config = {
model_list = [
{
model_name = "gpt-4o"
litellm_params = {
model = "openai/gpt-4o"
api_key = "os.environ/OPENAI_API_KEY"
}
},
]
general_settings = {
master_key = "os.environ/LITELLM_MASTER_KEY"
database_url = "os.environ/DATABASE_URL"
}
}

View file

@ -0,0 +1,82 @@
variable "location" {
description = "Azure region to deploy into."
type = string
default = "eastus"
}
variable "tenant" {
description = "Tenant slug used as the prefix for every Azure resource the stack creates."
type = string
default = "acme"
}
variable "env" {
description = "Environment suffix (e.g. `stage`, `prod`, `dev`)."
type = string
default = "stage"
}
variable "azs" {
description = "Azure availability zone identifiers (e.g. [\"1\", \"2\"])."
type = list(string)
default = ["1", "2"]
}
variable "litellm_master_key" {
description = "Pre-existing LiteLLM master key (must begin `sk-`). Leave empty to have the stack auto-generate."
type = string
default = ""
sensitive = true
}
variable "litellm_license" {
description = "Optional LiteLLM enterprise license."
type = string
default = ""
sensitive = true
}
variable "ui_password" {
description = "Optional UI admin password."
type = string
default = ""
sensitive = true
}
variable "key_vault_certificate_id" {
description = "Resource ID of an existing Key Vault certificate for the App Gateway HTTPS listener. Leave empty to deploy HTTP-only (set `allow_plaintext_app_gateway = true` too)."
type = string
default = ""
}
variable "allow_plaintext_app_gateway" {
description = "Allow the Application Gateway to serve HTTP without TLS. Dev / trial only."
type = bool
default = true
}
variable "storage_force_destroy" {
description = "Allow `terraform destroy` to remove a non-empty storage container."
type = bool
default = false
}
variable "proxy_config" {
description = "LiteLLM proxy config map (mirrors helm chart gateway.config.proxy_config)."
type = any
default = {
model_list = [
{
model_name = "gpt-4o"
litellm_params = {
model = "openai/gpt-4o"
api_key = "os.environ/OPENAI_API_KEY"
}
},
]
general_settings = {
master_key = "os.environ/LITELLM_MASTER_KEY"
database_url = "os.environ/DATABASE_URL"
}
}
}

View file

@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.117"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}

View file

@ -0,0 +1,55 @@
# ---------- Managed identities ----------
#
# One user-assigned managed identity shared across gateway / backend /
# ui / migrations Container Apps. Same identity is referenced as
# `azurerm_container_app.identity` -> user-assigned and granted the
# minimal set of role assignments below:
# - AcrPull on the Container Apps managed registry
# - Storage Blob Data on the Storage Account (cache / files)
# - Key Vault Secrets on the Key Vault (read master key + secrets)
# - Reader on its own resource group (private endpoint lookups)
resource "azurerm_user_assigned_identity" "container_apps" {
name = "${local.name}-mi"
location = var.location
resource_group_name = local.resource_group_name
tags = local.tags
}
# ---------- Role assignments ----------
#
# Granted inside the same module so a fresh `terraform apply` ends with a
# fully-wired stack; nothing to add post-apply aside from `db_bootstrap_sql`.
resource "azurerm_role_assignment" "storage_blob_data_contributor" {
scope = azurerm_storage_account.this.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.container_apps.principal_id
principal_type = "ServicePrincipal"
}
# Key Vault access is granted via access policies (data actions, scoped
# per-secret); see keyvault.tf.
# ---------- Container Apps Environment infrastructure role ----------
#
# The ACA environment requires a "managed identity" with the
# "Container Apps Environment Managed Identity" role on its own resource
# group (defined by Microsoft.App) so it can read ACR images and pull
# from the registry. We grant the user-assigned MI that role once.
# (For modular simplicity the ACA managed identity is the same identity
# we already created above.)
resource "azurerm_role_assignment" "container_apps_env_infra" {
scope = local.resource_group_id
role_definition_name = "Container Apps Environment Managed Identity Contributor"
principal_id = azurerm_user_assigned_identity.container_apps.principal_id
principal_type = "ServicePrincipal"
}
locals {
# Resolve the resource-group ID whether the caller supplied an existing
# RG (no resource here) or the module created one. Falls back to the
# resource's own id if a known format conversion is needed.
resource_group_id = var.resource_group_name == "" ? azurerm_resource_group.this[0].id : "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${var.resource_group_name}"
}

View file

@ -0,0 +1,169 @@
# Key Vault holds the proxy's secret material:
# - master-key secret (LITELLM_MASTER_KEY)
# - optional license (LITELLM_LICENSE)
# - optional UI password (UI_PASSWORD)
# - Postgres admin password (PG_ADMIN_PASSWORD) for the bootstrap step
#
# The Container Apps managed identity is granted per-secret access via
# access policies. Caller-supplied secrets (`gateway_extra_secrets`,
# `backend_extra_secrets`) are referenced by their secret IDs and not
# stored in this module; callers wire provider keys with their own
# `azurerm_key_vault_secret` resources (or supply them externally).
resource "random_password" "db_admin_password" {
length = 32
special = true
override_special = "@%*_+-:?#"
}
resource "random_password" "litellm_master_key" {
length = 43
special = false
}
# Generate the master key with the `sk-` prefix the proxy expects.
locals {
litellm_master_key_value = var.litellm_master_key != "" ? var.litellm_master_key : "sk-${substr(random_password.litellm_master_key.result, 0, 43)}"
}
resource "azurerm_key_vault" "this" {
name = replace("${var.tenant}${var.env}kv", "-", "") # no dashes allowed in KV name; truncated by Azure
location = var.location
resource_group_name = local.resource_group_name
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
tags = local.tags
# Networking: lock down to the VNet's private endpoint subnet. Caller's
# terraform principal needs to also be granted access to add secrets.
public_network_access_enabled = false
enable_rbac_authorization = false
enabled_for_deployment = false
enabled_for_disk_encryption = false
enabled_for_template_deployment = false
purge_protection_enabled = true
soft_delete_retention_days = 7
network_acls {
bypass = "AzureServices"
default_action = "Deny"
ip_rules = []
virtual_network_subnet_ids = [
azurerm_subnet.private_endpoints.id,
]
}
}
# ---------- Key Vault access policies ----------
#
# Three identities get secret access:
# 1. The Container Apps managed identity (read-only on all secrets)
# 2. The current terraform principal (so `terraform apply` can add
# secrets during creation; matches the AWS module's bootstrap pattern)
# 3. Possibly AAD admins for break-glass (not configured by default)
resource "azurerm_key_vault_access_policy" "container_apps" {
key_vault_id = azurerm_key_vault.this.id
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = azurerm_user_assigned_identity.container_apps.principal_id
secret_permissions = [
"Get", "List",
]
}
resource "azurerm_key_vault_access_policy" "terraform" {
key_vault_id = azurerm_key_vault.this.id
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = data.azurerm_client_config.current.object_id
secret_permissions = [
"Get", "List", "Set", "Delete", "Purge", "Recover",
]
}
# ---------- Secrets ----------
#
# The DB admin password lives in the vault and is consumed by the
# bootstrap job (which runs `psql` once to create the Entra token-auth
# app user; see `bootstrap.tf` / `iam.tf`). The proxy itself never sees
# it; gateway/backend/migrations use Entra tokens via the Container
# Apps managed identity.
resource "azurerm_key_vault_secret" "db_admin_password" {
name = "db-admin-password"
value = random_password.db_admin_password.result
key_vault_id = azurerm_key_vault.this.id
depends_on = [azurerm_key_vault_access_policy.terraform]
}
resource "azurerm_key_vault_secret" "master_key" {
name = "litellm-master-key"
value = local.litellm_master_key_value
key_vault_id = azurerm_key_vault.this.id
tags = local.tags
depends_on = [azurerm_key_vault_access_policy.terraform]
}
resource "azurerm_key_vault_secret" "license" {
count = var.litellm_license != "" ? 1 : 0
name = "litellm-license"
value = var.litellm_license
key_vault_id = azurerm_key_vault.this.id
tags = local.tags
depends_on = [azurerm_key_vault_access_policy.terraform]
}
resource "azurerm_key_vault_secret" "ui_password" {
count = var.ui_password != "" ? 1 : 0
name = "ui-password"
value = var.ui_password
key_vault_id = azurerm_key_vault.this.id
tags = local.tags
depends_on = [azurerm_key_vault_access_policy.terraform]
}
# ---------- Private endpoint ----------
#
# Pulls the vault onto a private IP in `private_endpoints` subnet so the
# Container Apps don't need public access to reach secrets.
resource "azurerm_private_endpoint" "keyvault" {
name = "${local.name}-kv-pe"
location = var.location
resource_group_name = local.resource_group_name
subnet_id = azurerm_subnet.private_endpoints.id
tags = local.tags
private_service_connection {
name = "${local.name}-kv"
private_connection_resource_id = azurerm_key_vault.this.id
is_manual_connection = false
subresource_names = ["vault"]
}
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [azurerm_private_dns_zone.keyvault.id]
}
depends_on = [azurerm_private_dns_zone_virtual_network_link.keyvault]
}
resource "azurerm_private_dns_zone" "keyvault" {
name = "privatelink.vaultcore.azure.net"
resource_group_name = local.resource_group_name
tags = local.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "keyvault" {
name = "${local.name}-kv-dnslink"
resource_group_name = local.resource_group_name
private_dns_zone_name = azurerm_private_dns_zone.keyvault.name
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
}

View file

@ -0,0 +1,337 @@
# Application Gateway: the Azure equivalent of AWS ALB. Public-facing;
# path-based routing that mirrors the AWS stack:
# - LLM data-plane paths (`/v1/*`, `/chat/*`, ...full list in
# locals.gateway_path_prefixes) -> gateway backend pool
# - UI asset paths (`/_next/*`, `/assets/*`, ...) -> ui backend pool
# - everything else (management API) -> backend backend pool
#
# TLS termination happens at the gateway when `key_vault_certificate_id`
# is supplied. Otherwise plaintext is only allowed when
# `allow_plaintext_app_gateway` is true.
#
# Container Apps ingress is internal-only (no external_enabled). The App
# Gateway reaches them via the Container Apps Environment default domain
# (e.g. `<leaf>.azurecontainerapps.io`) on the public DNS.
# ---------- Subnet for the Application Gateway ----------
resource "azurerm_subnet" "app_gateway" {
name = "app-gateway"
resource_group_name = local.resource_group_name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [cidrsubnet(var.vnet_cidr, 4, 2)] # /20 portion, isolated from container workloads
service_endpoints = ["Microsoft.Storage"]
}
resource "azurerm_network_security_group" "app_gateway" {
name = "${local.name}-appgw-nsg"
location = var.location
resource_group_name = local.resource_group_name
tags = local.tags
}
# App Gateway requires explicit public-inbound rules on ports 80/443.
resource "azurerm_network_security_rule" "app_gateway_http_in" {
count = var.allow_plaintext_app_gateway ? 1 : 0
name = "http-in"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
resource_group_name = local.resource_group_name
network_security_group_name = azurerm_network_security_group.app_gateway.name
}
resource "azurerm_network_security_rule" "app_gateway_https_in" {
name = "https-in"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
resource_group_name = local.resource_group_name
network_security_group_name = azurerm_network_security_group.app_gateway.name
}
resource "azurerm_subnet_network_security_group_association" "app_gateway" {
subnet_id = azurerm_subnet.app_gateway.id
network_security_group_id = azurerm_network_security_group.app_gateway.id
}
# ---------- Public IP ----------
resource "azurerm_public_ip" "app_gateway" {
name = "${local.name}-appgw-pip"
location = var.location
resource_group_name = local.resource_group_name
sku = "Standard"
allocation_method = "Static"
tags = local.tags
}
# ---------- Gateway FQDN lookup ----------
#
# Container Apps with internal ingress expose a private FQDN (only
# resolvable from inside the VNet). We feed those FQDNs into the App
# Gateway backend pools.
locals {
gateway_fqdn = azurerm_container_app.gateway.ingress[0].fqdn
backend_fqdn = azurerm_container_app.backend.ingress[0].fqdn
ui_fqdn = azurerm_container_app.ui.ingress[0].fqdn
}
# ---------- URL path map ----------
#
# Application Gateway URL path map contains paths -> backend pools. We
# build one rule that combines gateway and ui prefixes into a single
# path rule (Application Gateway allows multiple paths per rule via the
# `paths` list). The default backend is `backend`.
#
# The redirect_listen_priority uses 100 for the path map and 200 for
# the default backend redirect rule.
resource "azurerm_application_gateway" "this" {
name = "${local.name}-appgw"
location = var.location
resource_group_name = local.resource_group_name
tags = local.tags
sku {
name = "Standard_v2"
tier = "Standard_v2"
capacity = 2
}
# WAF v2 is the standard tier's upgrade; this baseline keeps the SKU
# minimal. Callers needing WAF can swap the SKU without re-creating
# the gateway (a single azurerm update).
enable_http2 = true
frontend_port {
name = "http"
port = 80
}
frontend_port {
name = "https"
port = 443
}
# Gateway-level IP config: the subnet the App Gateway instance listens
# on (a dedicated subnet with `Microsoft.Network/applicationGateways`
# delegated). Required even when the listener uses public_ip_address_id.
gateway_ip_configuration {
name = "gateway"
subnet_id = azurerm_subnet.app_gateway.id
}
# Frontend (listener-facing) IP config: the public IP that the world
# reaches the App Gateway at. Read via the `app_gateway_fqdn` output.
frontend_ip_configuration {
name = "public"
public_ip_address_id = azurerm_public_ip.app_gateway.id
}
# ---------- Backend pools ----------
backend_address_pool {
name = "gateway"
fqdns = [local.gateway_fqdn]
}
backend_address_pool {
name = "backend"
fqdns = [local.backend_fqdn]
}
backend_address_pool {
name = "ui"
fqdns = [local.ui_fqdn]
}
# ---------- Backend HTTP settings ----------
backend_http_settings {
name = "gateway-http"
cookie_based_affinity = "Disabled"
port = 443
protocol = "Https"
request_timeout = 60
probe_name = "gateway-probe"
host_name = local.gateway_fqdn
}
backend_http_settings {
name = "backend-http"
cookie_based_affinity = "Disabled"
port = 443
protocol = "Https"
request_timeout = 60
probe_name = "backend-probe"
host_name = local.backend_fqdn
}
backend_http_settings {
name = "ui-http"
cookie_based_affinity = "Disabled"
port = 443
protocol = "Https"
request_timeout = 60
probe_name = "ui-probe"
host_name = local.ui_fqdn
}
# ---------- Health probes ----------
probe {
name = "gateway-probe"
host = local.gateway_fqdn
interval = 30
timeout = 30
unhealthy_threshold = 3
match {
status_code = ["200-399"]
}
path = "/health/liveliness"
protocol = "Https"
}
probe {
name = "backend-probe"
host = local.backend_fqdn
interval = 30
timeout = 30
unhealthy_threshold = 3
match {
status_code = ["200-399"]
}
path = "/health/liveliness"
protocol = "Https"
}
probe {
name = "ui-probe"
host = local.ui_fqdn
interval = 30
timeout = 30
unhealthy_threshold = 3
match {
status_code = ["200-399"]
}
path = "/"
protocol = "Https"
}
# ---------- Listeners ----------
http_listener {
name = "http-listener"
frontend_ip_configuration_name = "public"
frontend_port_name = "http"
protocol = "Http"
host_name = null
}
http_listener {
name = "https-listener"
frontend_ip_configuration_name = "public"
frontend_port_name = "https"
protocol = "Https"
ssl_certificate_name = local.tls_enabled ? "tls" : null
host_name = null
}
# ---------- SSL certificate ----------
ssl_certificate {
name = "tls"
key_vault_secret_id = var.key_vault_certificate_id
# Only attach the SSL cert when TLS is enabled.
}
# ---------- URL path map ----------
#
# Single path rule with a default backend -> management API. The rule
# itself uses a path prefix matcher at "/" with all explicit paths in
# `local.gateway_path_prefixes` -> gateway backend and the UI paths in
# `local.ui_path_prefixes` -> ui backend. We leverage the
# "default_backend_address_pool" for the backend pool; explicit rules
# override the default.
url_path_map {
name = "litellm-url-map"
default_backend_address_pool_name = "backend"
default_backend_http_settings_name = "backend-http"
path_rule {
name = "gateway-prefixes"
paths = local.gateway_path_prefixes
backend_address_pool_name = "gateway"
backend_http_settings_name = "gateway-http"
}
path_rule {
name = "ui-prefixes"
paths = concat(local.ui_path_prefixes, local.ui_exact_paths)
backend_address_pool_name = "ui"
backend_http_settings_name = "ui-http"
}
}
# ---------- Request routing rules ----------
# HTTPS listener routes through the URL path map.
request_routing_rule {
name = "https-routing"
rule_type = "PathBasedRouting"
http_listener_name = "https-listener"
url_path_map_name = "litellm-url-map"
priority = 100
}
# HTTP listener either routes through the URL path map (when plaintext
# is allowed) or redirects to HTTPS.
dynamic "request_routing_rule" {
for_each = var.allow_plaintext_app_gateway ? [1] : []
content {
name = "http-routing"
rule_type = "PathBasedRouting"
http_listener_name = "http-listener"
url_path_map_name = "litellm-url-map"
priority = 110
}
}
dynamic "redirect_configuration" {
for_each = var.allow_plaintext_app_gateway ? [] : [1]
content {
name = "http-to-https"
redirect_type = "Permanent"
target_listener_name = "https-listener"
include_path = true
include_query_string = true
}
}
# Redirect rule for the http listener (only when plaintext is denied).
dynamic "request_routing_rule" {
for_each = var.allow_plaintext_app_gateway ? [] : [1]
content {
name = "http-redirect"
rule_type = "Basic"
http_listener_name = "http-listener"
redirect_configuration_name = "http-to-https"
priority = 200
}
}
}

View file

@ -0,0 +1,114 @@
# Per-component path prefixes mirrored verbatim from the AWS module's
# gateway_path_prefixes / ui_path_prefixes blocks (and ultimately from
# gateway/routes/allowlist.py plus the helm ingress in
# helm/litellm/templates/ingress.yaml). Anything not in either list and not
# a UI asset path falls through to the backend (management API) on the
# Application Gateway URL path map.
#
# Application Gateway URL path map rules cap path-based conditions per rule
# differently per SKU; for the path-based backend pool strategy used here
# we keep a single combined list and emit one rule per prefix.
locals {
# Every Azure resource the stack creates is named `<tenant>-litellm-<env>`
# (or that with a per-resource suffix). Computed once so the rest of the
# stack can reference `local.name`.
name = "${var.tenant}-litellm-${var.env}"
# Module-level tagging. Caller-provided provider default_tags merge with
# these at apply time.
tags = merge(
{
"litellm:stack" = local.name
"managed-by" = "terraform"
},
var.tags,
)
# Resource group that owns every resource the module creates. Caller may
# supply an existing one with var.resource_group_name; otherwise the
# module creates one named `<tenant>-litellm-<env>-rg`.
resource_group_name = var.resource_group_name != "" ? var.resource_group_name : "${local.name}-rg"
# Gateway data-plane path prefixes (mirrors AWS gateway_path_prefixes).
gateway_path_prefixes = [
"/v1/chat/*", "/chat/*",
"/v1/completions*", "/completions*",
"/v1/embeddings*", "/embeddings*",
"/v1/moderations*", "/moderations*",
"/v1/audio/*", "/audio/*",
"/v1/images/*", "/images/*",
"/v1/files*", "/files*",
"/v1/batches*", "/batches*",
"/v1/fine_tuning/*", "/fine_tuning/*",
"/v1/fine-tuning/*", "/fine-tuning/*",
"/v1/responses*", "/responses*",
"/v1/threads*", "/threads*",
"/v1/assistants*", "/assistants*",
"/v1/vector_stores*", "/vector_stores*",
"/v1/indexes*",
"/v1/models*", "/models*",
"/openai/*", "/engines/*",
"/v1/messages*", "/messages*",
"/v1/skills/*", "/v1/a2a/*",
"/v1/rerank*", "/v2/rerank*", "/rerank*",
"/v1/ocr*", "/ocr*",
"/v1/rag/*", "/rag/*",
"/v1/video/*", "/v1/videos/*", "/video/*", "/videos/*",
"/v1/search*", "/search*",
"/v1/containers/*", "/containers/*",
"/v1/evals/*",
"/v1/memory/*",
"/queue/chat/*",
"/v1beta/*",
"/interactions/*",
"/anthropic/*", "/azure/*", "/azure_ai/*", "/aws/*", "/bedrock/*",
"/cohere/*", "/gemini/*", "/google/*",
"/vertex_ai/*", "/vertex-ai/*",
"/assemblyai/*", "/eu.assemblyai/*",
"/langfuse/*", "/vllm/*",
"/mistral/*", "/groq/*", "/voyage/*", "/cursor/*", "/milvus/*",
"/openai_passthrough/*",
"/toolset/*",
"/v1/realtime*", "/realtime*",
"/health*", "/metrics", "/test*",
]
# Static UI asset prefixes (handled by the ui Container App, not the
# backend catch-all). / and /favicon.ico are added as exact-match paths.
ui_path_prefixes = [
"/litellm-asset-prefix/*",
"/_next/*",
"/assets/*",
"/ui/*",
]
ui_exact_paths = [
"/",
"/favicon.ico",
"/ui",
]
# TLS is enabled when a Key Vault certificate ID is supplied AND
# plaintext is disallowed (the default).
tls_enabled = var.key_vault_certificate_id != "" && !var.allow_plaintext_app_gateway
# Postgres admin setup: the first active-directory admin that provisions
# the Flexible Server. We use the current principal invoking terraform.
# (The `azure_ad_admin` block on the PG server accepts a specific object
# ID; callers needing a different admin can pass it through
# var.db_ad_admin_object_id.)
db_ad_admin_object_id_default = try(data.azurerm_client_config.current.object_id, "")
# Image URI resolution (mirrors AWS / GCP defaults):
# <image_registry>/<image_namespace>/litellm-<component>:<image_tag>
gateway_image_resolved = coalesce(var.gateway_image, "${var.image_registry}/${var.image_namespace}/litellm-gateway:${var.image_tag}")
backend_image_resolved = coalesce(var.backend_image, "${var.image_registry}/${var.image_namespace}/litellm-backend:${var.image_tag}")
ui_image_resolved = coalesce(var.ui_image, "${var.image_registry}/${var.image_namespace}/litellm-ui:${var.image_tag}")
migrations_image_resolved = coalesce(var.migrations_image, "${var.image_registry}/${var.image_namespace}/litellm-migrations:${var.image_tag}")
# Proxy config as YAML, uploaded to the storage account blob
# `config/litellm-config.yaml`. The Container Apps download this on
# startup via azure-identity and set `CONFIG_FILE_PATH=/tmp/litellm-config.yaml`.
proxy_config_yaml = var.proxy_config != {} ? yamlencode(var.proxy_config) : ""
}

View file

@ -0,0 +1,79 @@
# Container Apps Job for the one-off prisma migrate deploy. The job runs
# once after `terraform apply` and before any traffic; output the run
# command via `terraform output migration_run_command` and execute it
# via `az containerapp job start`.
#
# Like the AWS migrations task definition, this container downloads the
# proxy_config.yaml blob via the managed identity at startup, runs the
# schema migration, then exits. The downstream gateway / backend services
# do not depend on its success at terraform-apply time because the
# Container Apps revision bootstrap happens lazily; instead we rely on
# the bootstrap pattern in the README (run after apply, before first
# traffic).
resource "azurerm_container_app_job" "migrations" {
name = "${local.name}-migrations"
container_app_environment_id = azurerm_container_app_environment.this.id
resource_group_name = local.resource_group_name
location = var.location
tags = local.tags
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.container_apps.id]
}
# Manual trigger: one-shot job, runs to completion when invoked.
manual_trigger_config {
parallelism = 1
replica_completion_count = 1
}
replica_timeout_in_seconds = 1800
registry {
server = var.image_registry
}
# Secrets are declared at the job level and referenced from container
# env blocks via `secret_name`. Key Vault secret IDs are pulled at
# startup using the user-assigned managed identity declared on the job.
dynamic "secret" {
for_each = local.migrations_kv_secret_env
content {
name = secret.key
key_vault_secret_id = secret.value
identity = azurerm_user_assigned_identity.container_apps.id
}
}
template {
container {
name = "migrations"
image = local.migrations_image_resolved
cpu = 1.0
memory = "2Gi"
dynamic "env" {
for_each = concat(
local.migrations_base_env,
[
{ name = "DATABASE_URL", value = "postgresql://${var.db_username}@${azurerm_postgresql_flexible_server.this.fqdn}:5432/${var.db_name}?sslmode=require" },
],
)
content {
name = env.value.name
value = env.value.value
}
}
dynamic "env" {
for_each = local.migrations_kv_secret_env
content {
name = env.key
secret_name = env.key
}
}
}
}
}

View file

@ -0,0 +1,78 @@
# Caller-supplied resource group (when `resource_group_name` is set) or a
# freshly-created one. All Azure resources the module creates are scoped
# to the resource group named `local.resource_group_name`.
resource "azurerm_resource_group" "this" {
count = var.resource_group_name == "" ? 1 : 0
name = local.resource_group_name
location = var.location
tags = local.tags
}
data "azurerm_client_config" "current" {}
# Caller's subscription + tenant for role assignments and Key Vault access
# policies.
data "azurerm_subscription" "current" {}
# ---------- VNet ----------
#
# Subnet allocation:
# - containers : /23 (Container Apps Environment infrastructure subnet;
# dynamic IP allocation; must be /23 or larger per
# Azure docs)
# - private_endpoints : /24 (private endpoints to Postgres / Redis / Storage
# / Key Vault)
resource "azurerm_virtual_network" "this" {
name = "${local.name}-vnet"
location = var.location
resource_group_name = local.resource_group_name
address_space = [var.vnet_cidr]
tags = local.tags
}
resource "azurerm_subnet" "containers" {
name = "containers"
resource_group_name = local.resource_group_name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [cidrsubnet(var.vnet_cidr, 4, 0)] # /20 portion, plenty of room for Container Apps dynamic IPs
service_endpoints = ["Microsoft.Storage"]
delegation {
name = "container-apps"
service_delegation {
name = "Microsoft.App/environments"
actions = [
"Microsoft.Network/virtualNetworks/subnets/join/action",
"Microsoft.Network/virtualNetworks/subnets/prepareNetworkPolicies/action",
]
}
}
}
resource "azurerm_subnet" "private_endpoints" {
name = "private-endpoints"
resource_group_name = local.resource_group_name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [cidrsubnet(var.vnet_cidr, 4, 1)] # /20 portion, separate from containers
service_endpoints = ["Microsoft.Storage"]
}
# Network Security Group for the Container Apps subnet. Application
# Gateway sits in its own subnet (created below) and reaches the Container
# Apps via the internal ingress FQDN; the gateway subnet's NSG governs
# inbound traffic from the public internet.
resource "azurerm_network_security_group" "containers" {
name = "${local.name}-containers-nsg"
location = var.location
resource_group_name = local.resource_group_name
tags = local.tags
}
resource "azurerm_subnet_network_security_group_association" "containers" {
subnet_id = azurerm_subnet.containers.id
network_security_group_id = azurerm_network_security_group.containers.id
}

View file

@ -0,0 +1,89 @@
output "app_gateway_url" {
description = "Proxy URL. Scheme is https if key_vault_certificate_id is set, http otherwise. The dashboard is served at /, the API at /v1/*."
value = "${local.tls_enabled ? "https" : "http"}://${azurerm_public_ip.app_gateway.fqdn}"
}
output "app_gateway_fqdn" {
description = "Public FQDN of the LiteLLM Application Gateway (derived from the public IP)."
value = azurerm_public_ip.app_gateway.fqdn
}
output "container_apps_environment_id" {
description = "Resource ID of the Container Apps Environment hosting the gateway / backend / ui Container Apps."
value = azurerm_container_app_environment.this.id
}
output "postgres_fqdn" {
description = "FQDN of the Azure Database for PostgreSQL Flexible Server. Used by gateway / backend / migrations as `DATABASE_HOST`."
value = azurerm_postgresql_flexible_server.this.fqdn
}
output "postgres_database_name" {
description = "PostgreSQL database name."
value = var.db_name
}
output "redis_hostname" {
description = "Hostname of the Azure Cache for Redis (use the `rediss://` scheme if var.redis_enable_ssl is true)."
value = azurerm_redis_cache.this.hostname
}
output "redis_port" {
description = "Port of the Azure Cache for Redis."
value = azurerm_redis_cache.this.port
}
output "storage_account_name" {
description = "Name of the Storage Account hosting the proxy blob container for cache backend, request log archival, and /v1/files storage. Exposed to gateway + backend as `AZURE_STORAGE_ACCOUNT_NAME` (LiteLLM reads this env var to assemble credentials)."
value = azurerm_storage_account.this.name
}
output "storage_blob_container" {
description = "Blob container name on the Storage Account for proxy state."
value = azurerm_storage_container.proxy.name
}
output "key_vault_uri" {
description = "URI of the Key Vault holding LITELLM_MASTER_KEY, the Aurora master password bootstrap, optional LITELLM_LICENSE, and optional UI_PASSWORD."
value = azurerm_key_vault.this.vault_uri
}
output "master_key_secret_id" {
description = "Resource ID of the Key Vault secret holding LITELLM_MASTER_KEY."
value = azurerm_key_vault_secret.master_key.id
}
output "managed_identity_client_id" {
description = "Client ID of the user-assigned managed identity assigned to the Container Apps. Use this when granting additional role assignments outside the module."
value = azurerm_user_assigned_identity.container_apps.client_id
}
output "managed_identity_principal_id" {
description = "Object (principal) ID of the user-assigned managed identity. Use this for `az role assignment create` against additional scopes."
value = azurerm_user_assigned_identity.container_apps.principal_id
}
# Pre-baked SQL to run once as the Entra admin (after the first apply) to
# create the application user that gateway / backend / migration will
# authenticate as. Azure Database for PostgreSQL Flexible Server uses
# `azure_ad_admin` for the AAD-enabled login.
output "db_bootstrap_sql" {
description = "Run this once as the AAD admin (after the first apply) to create the application user that will authenticate via Entra-managed-identity tokens at runtime."
value = <<-SQL
CREATE USER "${var.db_username}" WITH LOGIN;
GRANT ALL PRIVILEGES ON DATABASE ${var.db_name} TO "${var.db_username}";
GRANT ALL ON SCHEMA public TO "${var.db_username}";
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "${var.db_username}";
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "${var.db_username}";
SQL
}
# Pre-baked az CLI command for the one-off migration Container App Job.
output "migration_run_command" {
description = "az CLI command that triggers the one-off prisma migration Container App Job. Run after the Entra admin has executed the db_bootstrap_sql above."
value = format(
"az containerapp job start --name %s --resource-group %s --subscription <your-sub-id>",
azurerm_container_app_job.migrations.name,
local.resource_group_name,
)
}

View file

@ -0,0 +1,78 @@
# Azure Database for PostgreSQL Flexible Server, single instance, with
# Entra (Azure AD) authentication enabled for the proxy's managed
# identity. (Single zone by default; set `zone = "1"` plus a high-
# availability SKU for zone redundancy.)
#
# The bootstrap job (run once during initial setup, see bootstrap.tf)
# creates the literal-login user used for password-based app access;
# the gateway / backend / migrations Container Apps use Entra tokens at
# runtime.
resource "azurerm_postgresql_flexible_server" "this" {
name = replace("${var.tenant}-${var.env}-pg", "-", "")
location = var.location
resource_group_name = local.resource_group_name
sku_name = var.db_sku_name
storage_mb = var.db_storage_mb
version = var.db_version
tags = local.tags
# The AAD admin: this object's principal can connect to the server and
# bootstrap the application user. Defaults to the current terraform
# principal; override var.db_ad_admin_object_id for a dedicated admin.
administrator_login = "litellm_admin"
administrator_password = random_password.db_admin_password.result
authentication {
password_auth_enabled = true
active_directory_auth_enabled = true
}
# Default to private access in the VNet; public access disabled.
public_network_access_enabled = false
delegated_subnet_id = azurerm_subnet.private_endpoints.id
private_dns_zone_id = azurerm_private_dns_zone.postgres.id
# Zone redundancy is off by default to control cost; flip on via
# `zone = "1"` + matching SKU.
zone = length(var.azs) > 1 ? var.azs[0] : null
depends_on = [
azurerm_role_assignment.container_apps_env_infra,
azurerm_private_dns_zone_virtual_network_link.postgres,
]
}
resource "azurerm_postgresql_flexible_server_database" "this" {
server_id = azurerm_postgresql_flexible_server.this.id
name = var.db_name
collation = "en_US.utf8"
charset = "UTF8"
}
# Set the AAD admin via a separate resource. The first principal in the
# list wins; we use the current terraform principal by default.
resource "azurerm_postgresql_flexible_server_active_directory_administrator" "this" {
server_name = azurerm_postgresql_flexible_server.this.name
resource_group_name = local.resource_group_name
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = data.azurerm_client_config.current.object_id
principal_name = "terraform-admin"
principal_type = "User"
depends_on = [azurerm_postgresql_flexible_server.this]
}
resource "azurerm_private_dns_zone" "postgres" {
name = "privatelink.postgres.database.azure.com"
resource_group_name = local.resource_group_name
tags = local.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "postgres" {
name = "${local.name}-pg-dnslink"
resource_group_name = local.resource_group_name
private_dns_zone_name = azurerm_private_dns_zone.postgres.name
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
}

View file

@ -0,0 +1,52 @@
# Azure Cache for Redis: single instance (Basic tier) by default.
# Production should move to Standard / Premium with replication.
#
# The proxy connects via `rediss://` (TLS) when `var.redis_enable_ssl`
# is true (the default, matching AWS module's transit_encryption_enabled).
resource "azurerm_redis_cache" "this" {
name = replace("${var.tenant}-${var.env}-redis", "-", "")
location = var.location
resource_group_name = local.resource_group_name
sku_name = var.redis_sku
family = var.redis_family
capacity = var.redis_capacity
enable_non_ssl_port = !var.redis_enable_ssl
minimum_tls_version = "1.2"
tags = local.tags
# Redis access keys are managed by Azure; the proxy uses the `rediss://`
# URL with the primary key as auth. Caller can rotate via Key Vault
# access policy if tighter secret-scoped handling is required.
redis_configuration {
# Renamed to authentication_enabled in v4. Until we drop 3.x support,
# both forms exist; this module targets 3.117+ which still accepts
# enable_authentication.
enable_authentication = true
maxmemory_policy = "allkeys-lru"
}
# Access via private endpoint only.
public_network_access_enabled = false
subnet_id = azurerm_subnet.private_endpoints.id
}
resource "azurerm_private_dns_zone" "redis" {
name = "privatelink.redis.cache.windows.net"
resource_group_name = local.resource_group_name
tags = local.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "redis" {
name = "${local.name}-redis-dnslink"
resource_group_name = local.resource_group_name
private_dns_zone_name = azurerm_private_dns_zone.redis.name
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
}
# Outputs that the gateway/backend pass via `REDIS_HOST` / `REDIS_PORT`.
locals {
redis_host_for_container_apps = azurerm_redis_cache.this.hostname
redis_port_for_container_apps = azurerm_redis_cache.this.ssl_port
}

View file

@ -0,0 +1,124 @@
# Storage Account: holds:
# - proxy_config.yaml (uploaded from var.proxy_config; refreshed on
# proxy_config changes to trigger Container App revision swap)
# - /v1/files passthrough storage (request log archival, file uploads)
# - log archive bucket (optional, future)
#
# LiteLLM reads `AZURE_STORAGE_ACCOUNT_NAME` and uses the Container Apps
# managed identity's Storage Blob Data Contributor role on this account
# to do CRUD with the SDK. No shared keys are issued.
resource "azurerm_storage_account" "this" {
name = replace("${var.tenant}${var.env}proxy", "-", "") # 24 char cap; lowercase + no dashes
location = var.location
resource_group_name = local.resource_group_name
account_tier = var.storage_account_tier
account_replication_type = var.storage_replication_type
account_kind = "StorageV2"
tags = local.tags
# Security defaults: TLS 1.2 minimum, blob public access blocked, shared
# keys off (managed identity only).
min_tls_version = "TLS1_2"
allow_nested_items_to_be_public = false
shared_access_key_enabled = false
public_network_access_enabled = false
blob_properties {
versioning_enabled = true
container_delete_retention_policy {
days = 7
}
delete_retention_policy {
days = 7
}
}
network_rules {
default_action = "Deny"
bypass = ["AzureServices"]
ip_rules = []
virtual_network_subnet_ids = [
azurerm_subnet.private_endpoints.id,
azurerm_subnet.containers.id,
]
}
}
# Container that holds the proxy config + /v1/files uploads.
resource "azurerm_storage_container" "proxy" {
name = "proxy"
storage_account_name = azurerm_storage_account.this.name
container_access_type = "private"
}
# Files subcontainer: dedicated for the /v1/files endpoint so callers can
# isolate the data plane from the proxy_config blob.
resource "azurerm_storage_container" "files" {
name = "files"
storage_account_name = azurerm_storage_account.this.name
container_access_type = "private"
}
# Private endpoint for the Storage Account (blob sub-resource).
resource "azurerm_private_endpoint" "storage_blob" {
name = "${local.name}-sa-pe"
location = var.location
resource_group_name = local.resource_group_name
subnet_id = azurerm_subnet.private_endpoints.id
tags = local.tags
private_service_connection {
name = "${local.name}-sa-blob"
private_connection_resource_id = azurerm_storage_account.this.id
is_manual_connection = false
subresource_names = ["blob"]
}
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
}
depends_on = [azurerm_private_dns_zone_virtual_network_link.blob]
}
resource "azurerm_private_dns_zone" "blob" {
name = "privatelink.blob.core.windows.net"
resource_group_name = local.resource_group_name
tags = local.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "blob" {
name = "${local.name}-sa-dnslink"
resource_group_name = local.resource_group_name
private_dns_zone_name = azurerm_private_dns_zone.blob.name
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
}
# ---------- proxy_config blob upload ----------
#
# Encoded from `var.proxy_config` (a typed map). Each apply that changes
# proxy_config produces a new blob content, which the gateway and
# backend Container Apps container start uses as a versioning trigger.
resource "azurerm_storage_blob" "proxy_config" {
count = var.proxy_config != {} ? 1 : 0
name = "config/litellm-config.yaml"
storage_account_name = azurerm_storage_account.this.name
storage_container_name = azurerm_storage_container.proxy.name
type = "Block"
content_type = "application/yaml"
# Force a fresh upload on content change. The lifecycle keeps the
# previous version accessible via the versioned blob endpoint.
source_content = local.proxy_config_yaml
depends_on = [
azurerm_role_assignment.storage_blob_data_contributor,
azurerm_storage_container.proxy,
]
}

View file

@ -0,0 +1,400 @@
variable "location" {
description = "Azure region to deploy into (e.g. `eastus`, `westus2`)."
type = string
}
variable "tenant" {
description = "Tenant slug, used as the prefix for every Azure resource the stack creates. Combined with var.env to form `<tenant>-litellm-<env>` (e.g. `acme-litellm-stage`)."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{0,20}$", var.tenant))
error_message = "tenant must be 1-21 chars, lower-kebab-case, starting with a letter."
}
}
variable "env" {
description = "Environment suffix appended to every resource name (e.g. `stage`, `prod`, `dev`)."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{0,8}$", var.env))
error_message = "env must be 1-9 chars, lower-kebab-case, starting a letter."
}
}
variable "tags" {
description = "Per-deployment tags applied to every taggable resource the module creates, on top of the module's own `litellm:stack` / `managed-by` tags. Caller-level provider default_tags (if any) merge with these."
type = map(string)
default = {}
}
variable "resource_group_name" {
description = "Existing resource group to deploy into. Leave empty to have the module create one named `<tenant>-litellm-<env>-rg`."
type = string
default = ""
}
# ---------- Tenant-supplied secrets ----------
#
# Both default to "" so the stack stays usable for trial / OSS deploys.
# Set via TF_VAR_litellm_master_key / TF_VAR_litellm_license to keep the
# values out of state files committed to a VCS.
variable "litellm_master_key" {
description = <<-EOT
Pre-existing LITELLM_MASTER_KEY (must begin with `sk-`). When set, this
value is written to the master-key Key Vault entry. When empty, the
stack auto-generates a random `sk-...` key (preserving today's
trial-deploy behavior).
EOT
type = string
default = ""
sensitive = true
}
variable "litellm_license" {
description = <<-EOT
LiteLLM enterprise license string. When set, the stack creates a
`<tenant>-litellm-<env>-license` Key Vault secret, grants the
Container Apps managed identity read access, and exposes its value to
gateway + backend as `LITELLM_LICENSE`. Leave empty for OSS-only deploys.
EOT
type = string
default = ""
sensitive = true
}
variable "ui_password" {
description = <<-EOT
UI admin password. When set, the stack creates a
`<tenant>-litellm-<env>-ui-password` Key Vault secret, grants the
Container Apps managed identity read access, and exposes its value to
the backend as `UI_PASSWORD`. Pair with `backend_extra_env.UI_USERNAME`
to set the matching username. Leave empty to skip; the proxy then
falls back to the LITELLM_MASTER_KEY for UI login.
EOT
type = string
default = ""
sensitive = true
}
# ---------- Networking ----------
variable "vnet_cidr" {
description = "CIDR block for the VNet."
type = string
default = "10.42.0.0/16"
}
variable "azs" {
description = "Availability zones to spread subnets across. Use Azure zone identifiers (e.g. `[\"1\", \"2\", \"3\"]`). At least 1 required; 2+ recommended for HA PostgreSQL Flexible Server."
type = list(string)
validation {
condition = length(var.azs) >= 1
error_message = "Provide at least 1 availability zone."
}
}
# ---------- Component images ----------
#
# Defaults pin the four componentized images at the same release tag on
# GHCR. Override on a per-component basis in tfvars when bumping; bump them
# together when bumping the LiteLLM release.
#
# Container Apps pulls `ghcr.io/berriai/litellm-gateway:<tag>` by default;
# this works as-is in Azure because Container Apps can pull from ghcr.io
# directly (the GCP stack requires an Artifact Registry mirror, AWS pulls
# from ECR only after a docker push, Azure is the closest to a direct
# pull-from-public-registry experience).
variable "image_registry" {
description = "Container registry host (without trailing slash). Defaults to GHCR; override when mirroring to ACR or another private registry."
type = string
default = "ghcr.io"
}
variable "image_namespace" {
description = "Container registry namespace / owner. Defaults to `berriai` on GHCR."
type = string
default = "berriai"
}
variable "image_tag" {
description = "Image tag (e.g. `v1.86.0-dev`). All four component images use the same tag; bump together when bumping LiteLLM."
type = string
default = "latest"
}
variable "gateway_image" {
description = "Container image for the gateway (data plane, port 4000). Defaults to `<image_registry>/<image_namespace>/litellm-gateway:<image_tag>`."
type = string
default = ""
}
variable "backend_image" {
description = "Container image for the backend (management API, port 4001). Defaults to `<image_registry>/<image_namespace>/litellm-backend:<image_tag>`."
type = string
default = ""
}
variable "ui_image" {
description = "Container image for the UI (port 3000). Defaults to `<image_registry>/<image_namespace>/litellm-ui:<image_tag>`."
type = string
default = ""
}
variable "migrations_image" {
description = "Container image for the one-off migration job. Defaults to `<image_registry>/<image_namespace>/litellm-migrations:<image_tag>`."
type = string
default = ""
}
# ---------- Database ----------
variable "db_sku_name" {
description = "Azure Database for PostgreSQL Flexible Server SKU (e.g. `Standard_B1ms`, `Standard_D2s_v3`, `GP_Standard_D2s_v3`)."
type = string
default = "Standard_B1ms"
}
variable "db_version" {
description = "PostgreSQL major version (e.g. `15`, `16`, `17`)."
type = string
default = "16"
}
variable "db_storage_mb" {
description = "Storage size in MB for the PostgreSQL Flexible Server."
type = number
default = 32768
}
variable "db_name" {
description = "Database name to create on the PostgreSQL Flexible Server."
type = string
default = "litellm"
}
variable "db_username" {
description = "Application DB username. The Container Apps managed identity uses Entra (Azure AD) token auth at runtime; this user is created during `terraform apply` by the bootstrap job and granted the rights the proxy needs."
type = string
default = "litellm_app"
}
# ---------- Redis ----------
variable "redis_sku" {
description = "Azure Cache for Redis SKU. Defaults to `Basic` (single node, dev/trial); `Standard` or `Premium` recommended for production (zone redundancy + replication)."
type = string
default = "Basic"
}
variable "redis_family" {
description = "Redis SKU family. `C` is basic; `P` is premium."
type = string
default = "C"
}
variable "redis_capacity" {
description = "Cache capacity for Azure Cache for Redis (0 = 250MB, 1 = 1GB, ...). Match to your expected throughput."
type = number
default = 0
}
variable "redis_enable_ssl" {
description = "Whether to require SSL for Redis connections. Defaults to true to match AWS module's `transit_encryption_enabled`."
type = bool
default = true
}
# ---------- Storage ----------
variable "storage_force_destroy" {
description = "Allow `terraform destroy` to delete a non-empty Azure Storage container (and the Storage Account). Off by default to protect cached responses, archived request logs, and /v1/files storage."
type = bool
default = false
}
variable "storage_account_tier" {
description = "Storage Account performance tier. `Standard` is fine for cache / log archive / file storage; `Premium` for low-latency workloads."
type = string
default = "Standard"
}
variable "storage_replication_type" {
description = "Storage Account replication type (`LRS`, `GRS`, `RAGRS`, `ZRS`). Defaults to LRS for cost; bump to GRS / ZRS for production durability."
type = string
default = "LRS"
}
# ---------- TLS / Load balancer ----------
variable "key_vault_certificate_id" {
description = <<-EOT
Resource ID of an existing Key Vault certificate to attach to the
Application Gateway listener for TLS termination. When unset, the
Application Gateway listener uses HTTP only; either set this var OR
set `allow_plaintext_app_gateway = true` for dev/trial only.
EOT
type = string
default = ""
}
variable "allow_plaintext_app_gateway" {
description = "Allow the Application Gateway listener to serve HTTP (port 80) without TLS. Dev / trial only. Defaults to false to match the AWS module's secure default."
type = bool
default = false
}
# ---------- Compute sizing ----------
variable "gateway_cpu" {
description = "CPU allocation for the gateway Container App in 0.25 vCPU increments (e.g. `0.5`, `1.0`, `2.0`)."
type = number
default = 1.0
}
variable "gateway_memory" {
description = "Memory for the gateway Container App, e.g. `2Gi`, `4Gi`."
type = string
default = "2Gi"
}
variable "gateway_min_replicas" {
description = "Minimum replicas for the gateway Container App (autoscaler lower bound)."
type = number
default = 2
}
variable "gateway_max_replicas" {
description = "Maximum replicas for the gateway Container App (autoscaler upper bound)."
type = number
default = 10
}
variable "backend_cpu" {
description = "CPU allocation for the backend Container App."
type = number
default = 1.0
}
variable "backend_memory" {
description = "Memory for the backend Container App."
type = string
default = "2Gi"
}
variable "backend_min_replicas" {
description = "Minimum replicas for the backend Container App."
type = number
default = 1
}
variable "backend_max_replicas" {
description = "Maximum replicas for the backend Container App."
type = number
default = 5
}
variable "ui_cpu" {
description = "CPU allocation for the UI Container App."
type = number
default = 0.5
}
variable "ui_memory" {
description = "Memory for the UI Container App."
type = string
default = "1Gi"
}
variable "ui_min_replicas" {
description = "Minimum replicas for the UI Container App."
type = number
default = 1
}
variable "ui_max_replicas" {
description = "Maximum replicas for the UI Container App."
type = number
default = 3
}
# ---------- proxy_config and extra config ----------
variable "proxy_config" {
description = <<-EOT
Mirrors the helm chart's `gateway.config.proxy_config`. The map is
YAML-encoded and uploaded to the storage account blob `config/litellm-config.yaml`;
the gateway and backend Container Apps download it to
`/tmp/litellm-config.yaml` at startup via the SDK and set
`CONFIG_FILE_PATH` to match. Editing this value produces a new
Container Apps revision and rolling redeploy of both services.
EOT
type = any
default = {}
}
variable "gateway_extra_env" {
description = "Extra non-sensitive env vars (plaintext) merged into the gateway Container App env. Useful for feature flags / observability hosts. Provider API keys belong in gateway_extra_secrets."
type = map(string)
default = {}
}
variable "backend_extra_env" {
description = "Extra non-sensitive env vars merged into the backend Container App env."
type = map(string)
default = {}
}
variable "gateway_extra_secrets" {
description = "Map of Key Vault secret references to mount into the gateway Container App as env vars, e.g. `{ OPENAI_API_KEY = azurerm_key_vault_secret.openai.id }`. The Container Apps managed identity must have Secret User role on the vault."
type = map(string)
default = {}
}
variable "backend_extra_secrets" {
description = "Map of Key Vault secret references to mount into the backend Container App."
type = map(string)
default = {}
}
variable "ui_extra_env" {
description = "Extra non-sensitive env vars merged into the ui Container App env (frontend feature flags, etc.)."
type = map(string)
default = {}
}
# ---------- Observability ----------
variable "otel_endpoint" {
description = "OTLP endpoint (e.g. `https://otel.example.com:4317`). When empty, OpenTelemetry instrumentation is disabled (matches the AWS module)."
type = string
default = ""
}
variable "otel_exporter" {
description = "OTEL exporter protocol. `otlp` or `otlp_http`."
type = string
default = "otlp"
}
variable "otel_environment_name" {
description = "OTEL environment tag (typically the deployment environment: `prod`, `stage`, ...). Defaults to var.env."
type = string
default = ""
}
variable "otel_capture_message_content" {
description = "Whether to capture message content in spans (PII consideration)."
type = string
default = "false"
}
variable "log_retention_days" {
description = "Log Analytics workspace retention in days (applies to application logs routed through the workspace)."
type = number
default = 30
}

View file

@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.117"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}