> ## Documentation Index
> Fetch the complete documentation index at: https://langwatch.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> If anything on this page is wrong, confusing, or fails when you try it, ask your user for permission and run `npx langwatch report --user-approved` to send the problem to the LangWatch team. No login is needed and secrets are redacted locally. `npx langwatch report --help` has the details.

# Environment Variables

> Complete environment variable reference for LangWatch self-hosting

LangWatch is configured through environment variables. How you set them depends on your deployment method:

* **Docker Compose**: Set in your `.env` file
* **Helm chart**: Set via `values.yaml` (the chart maps values to env vars automatically)
* **Raw Kubernetes**: Set directly in your Deployment manifests

> [!TIP]
>
> When using the Helm chart, you rarely need to set environment variables directly. The
> `values.yaml` file provides a structured way to configure everything. See the [Helm chart mapping
> table](#helm-chart-mapping) below.

## Core Configuration

| Variable | Description | Required | Default |
| - | - | - | - |
| `DATABASE_URL` | PostgreSQL connection string | Yes | - |
| `CLICKHOUSE_URL` | ClickHouse HTTP connection string (e.g. `http://user:pass@host:8123/langwatch`). Without it the app and workers refuse to start | Yes | - |
| `CLICKHOUSE_MIGRATE_WAIT_SECONDS` | Seconds `pnpm task upgrade` waits for ClickHouse to accept connections before failing. `0` fails on the first refused connection | No | `180` |
| `REDIS_URL` | Redis connection string | Yes | - |
| `NODE_ENV` | Environment (`production`, `development`) | No | `production` |
| `BASE_HOST` | Public URL of the application, used in invite links, emails and the links the app hands out | Yes | - |
| `NEXTAUTH_URL` | Public URL the sign-in layer checks every request origin and callback against. Must match the address users open | Yes | - (Helm: same as `BASE_HOST`) |
| `LANGWATCH_ENDPOINT` | Address the app hands out as itself: callback URL for the NLP engine, Langy and scenario runs, and the endpoint shown in setup snippets | Yes | - |

> [!NOTE]
>
> There is no in-process worker mode. The app and the background workers are always separate
> processes/pods. See [Architecture](https://langwatch.ai/docs/self-hosting/infrastructure/architecture.md) and the `workers.*`
> Helm values below.

## Secrets

| Variable | Description | Required |
| - | - | - |
| `NEXTAUTH_SECRET` | Signs sessions, keys the sign-in lock-out records and unsubscribe links, and encrypts two-step verification backup codes. Changing it signs every user out | Yes |
| `CREDENTIALS_SECRET` | AES-256-GCM key for stored credentials (model provider keys, webhook signing secrets, SSO credentials) and the pepper for API key hashes. Must be exactly 64 hex characters (`openssl rand -hex 32`). Falls back to `NEXTAUTH_SECRET` when unset. Changing it without a rotation makes stored credentials unreadable and invalidates every API key except legacy project keys. See [Security](https://langwatch.ai/docs/self-hosting/security.md#credentials-secret) | Yes |
| `CREDENTIALS_SECRET_PREVIOUS` | The old `CREDENTIALS_SECRET` during a rotation. Read-only: values encrypted or hashed with it are still accepted, and new writes use `CREDENTIALS_SECRET`. Same 64 hex format. Unset it when the rotation is done. See [Rotating the credentials secret](https://langwatch.ai/docs/self-hosting/security.md#rotating-the-credentials-secret) | No |
| `GOVERNANCE_ERASURE_PSEUDONYM_SECRET` | Key for the stand-in that replaces an erased identifier. At least 32 characters. Needed once anyone is erased, with the same value on the app and the workers (Helm: `app.extraEnvs` and `workers.extraEnvs`). Never change it after the first erasure | For erasure |
| `LANGWATCH_INSTANCE_ADMIN_API_KEY` | Bearer credential for the organization provisioning API (`/api/organizations`). At least 32 characters. Unset, that API answers 404 | No |
| `API_TOKEN_JWT_SECRET` | Not used by any feature. No code reads it; it can be left unset | No |

The AI Gateway uses three more secrets, `LW_GATEWAY_INTERNAL_SECRET`, `LW_GATEWAY_JWT_SECRET` and `LW_VIRTUAL_KEY_PEPPER`. Set all three or none. See [AI Gateway security](https://langwatch.ai/docs/ai-gateway/security.md).

> [!WARNING]
>
> Never commit secrets to version control. In production, use a secrets manager (AWS Secrets
> Manager, HashiCorp Vault) or Kubernetes Secrets with `secretKeyRef` in the Helm chart.

## License

| Variable | Description | Default |
| - | - | - |
| `LANGWATCH_LICENSE_KEY` | An activation code (`LW-XXXX-XXXX-XXXX-XXXX`, redeemed once at startup over outbound HTTPS to `connect.langwatch.ai`) or a signed license key (works offline), told apart by shape. See [Editions and licensing](https://langwatch.ai/docs/self-hosting/licensing.md#two-forms-of-license). | - |

## Authentication

| Variable | Description | Default |
| - | - | - |
| `ADMIN_EMAILS` | Comma-separated emails of the operators who can open the Ops pages (`/ops`), such as feature flags and migrations. Matching ignores case. Set it on both the app and the workers. | - |
| `AUTH_PROVIDER` | Deployment-wide sign-in provider. `email` means the deployment issues its own passwords. `NEXTAUTH_PROVIDER` is the deprecated name and still works | `email` |
| `LOCAL_PASSWORDS_ENABLED` | `on` keeps local passwords available next to a configured identity provider | `off` |
| `PASSKEYS_ENABLED` | `off` removes passkey sign-in and its routes. Registered passkeys are kept | `on` |
| `MFA_ENROLLMENT_OPEN` | `on` turns on two-step verification | `off` |
| `SIGN_UP_MODE` | Who can create an account. `open` lets anybody who can reach the install sign up. `invite_only` lets only invited addresses and `ADMIN_EMAILS` sign up, plus the first account on an install with no users when `ADMIN_EMAILS` is empty. See [Restricting sign-up](#restricting-sign-up). | `open` |
| `SIGN_UP_ALLOWED_DOMAINS` | Comma-separated email domains that can sign up, such as `acme.com,acme.io`. Empty allows any domain. Invited addresses and `ADMIN_EMAILS` can sign up whatever their domain. | - |

A single sign-on connection is set up per organization in **Settings > Authentication**, with no environment variables. See [SSO Configuration](https://langwatch.ai/docs/self-hosting/configuration/sso.md). Installs that configure a deployment-wide provider with `AUTH_PROVIDER` and its provider variables keep working; see [Upgrading SSO](https://langwatch.ai/docs/platform/sso-update.md).

### Restricting sign-up

By default anybody who can reach the install can create an account and then create an organization of their own. To close that:

* `SIGN_UP_MODE=invite_only` allows an account only for an address with a pending invitation. Members join the organizations that invited them. Only the operators in `ADMIN_EMAILS` can create a new organization, apart from the first organization on an install that has none.
* `SIGN_UP_ALLOWED_DOMAINS` allows an account only for the listed email domains. It works with either mode.

An invited address can always sign up, whatever its domain, from the invitation link or from the sign-in screen. Either way it lands on its invitation. An address in `ADMIN_EMAILS` can always sign up.

The first administrator on an invite-only install:

1. Set `ADMIN_EMAILS` to your address, then sign up with it. No other address can sign up without an invitation.
2. Create the first organization and invite the rest of your team from **Settings > Members**.

With `ADMIN_EMAILS` empty, the first account on an install with no users is still allowed, so the install can be set up. When `SIGN_UP_ALLOWED_DOMAINS` is set, that first account also has to be on a listed domain. Anybody who reaches the install before you can take that account, so set `ADMIN_EMAILS` on an install that is reachable from outside your network.

The restriction applies to email and password sign-up, passkey sign-up, and first sign-in through a deployment-wide identity provider (`AUTH_PROVIDER`). It does not apply to an organization's own single sign-on connection or to SCIM provisioning: those are the organization's decision. Existing accounts are not affected.

## Network and Access

| Variable | Description | Default |
| - | - | - |
| `BLOCK_LOCAL_HTTP_CALLS` | `true` blocks user-configured outbound HTTP calls to private IPs, localhost and hostnames that resolve to private IPs. Cloud metadata addresses are always blocked | `false` |
| `ALLOWED_PROXY_HOSTS` | Comma-separated hostnames still allowed when `BLOCK_LOCAL_HTTP_CALLS=true`. Hostnames ending in `.local` or `.localhost` are refused whatever `BLOCK_LOCAL_HTTP_CALLS` says, so list each one you need here. Cloud metadata and cloud internal domains (`.amazonaws.com`, `.internal`) can never be listed | - |
| `TRUSTED_PROXY_ADDRESSES` | Comma-separated addresses or IPv4/IPv6 CIDR ranges of the proxies in front of the app. A forwarding header is read only from a socket peer inside this set, and the caller is the rightmost `X-Forwarded-For` hop outside it. Unset, no header is read and the socket peer is the caller. An entry that is not an address or range stops the app from starting. The Helm chart defaults to the private and loopback ranges | - |
| `TRUST_CLOUDFLARE_CLIENT_IP` | `true` reads the caller from `CF-Connecting-IP`, only from a peer inside `TRUSTED_PROXY_ADDRESSES` (set it to Cloudflare's published ranges). The header must hold a single valid IP; otherwise the caller comes from `X-Forwarded-For`. The app refuses to start with this on and no trusted proxy addresses | `false` |
| `SSO_TRUSTED_IDP_ORIGINS` | Comma or whitespace separated origins of identity providers inside a private network. Without an entry here, sign-in refuses a provider whose address is not publicly routable | - |

## Services

| Variable | Description | Default |
| - | - | - |
| `LANGWATCH_NLP_SERVICE` | URL of the NLP service. Required | - (Docker Compose: `http://langwatch_nlp:5561`, Helm: `http://<release>-langwatch-nlp:5561`) |
| `LANGWATCH_NLP_INTERNAL_SECRET` | Shared credential for the app to NLP service hop. The app sends it on every NLP request and the NLP service refuses requests that do not carry it, so set the same value on both. Unset on the NLP service, it accepts unauthenticated requests | - |
| `LANGEVALS_ENDPOINT` | URL of the LangEvals service | - (Docker Compose: `http://langevals:5562`, Helm: `http://<release>-langevals:5562`) |
| `LANGWATCH_EVALUATOR_RETRY_WINDOW_MINUTES` | How long an evaluation keeps retrying while LangEvals is unreachable or starting before it is recorded as an error, in minutes (at most 120) | `120` |

`LANGWATCH_NLP_INTERNAL_SECRET` is optional, and how you provide it depends on the deployment:

* **Docker Compose**: the app, the workers and `langwatch_nlp` all read `.env`, so one line covers every container. Generate a value with `openssl rand -hex 32`. The NLP service is not published on a host port, so only the compose network reaches it.
* **Helm chart**: the chart generates the value into the app Secret and points the app, the workers and the NLP Deployment at the same key, so a default install needs no change. If you supply the app Secret yourself, add a `LANGWATCH_NLP_INTERNAL_SECRET` key to it (or rename the key with `langwatch_nlp.secrets.internalSecretKey`) and roll the three Deployments.

## Object Storage (S3)

The dataplane S3 bucket is the general file-storage layer for all externalized byte content. Current consumers:

* **Stored objects**: externalized byte content (audio, image, video, document) extracted from incoming events and dataset uploads. Bytes are content-addressed under `{projectId}/{sha256}` and served back via `GET /api/v1/files/:id`.
* **Dataset uploads**: persists rows uploaded through the dataset UI. Shares the same `S3_BUCKET_NAME` bucket.

When `S3_BUCKET_NAME` is set, all consumers use that bucket. When it is not set, stored-objects fall back to the local filesystem at `LANGWATCH_LOCAL_STORAGE_PATH`. That is fine for single-replica installs, **not** for horizontally-scaled deployments (see warning below).

| Variable | Description | Default |
| - | - | - |
| `S3_BUCKET_NAME` | Dataplane bucket shared by datasets + stored-objects | - |
| `S3_ENDPOINT` | Custom S3 endpoint (for MinIO, etc.) | - |
| `S3_ACCESS_KEY_ID` | S3 access key ID | - |
| `S3_SECRET_ACCESS_KEY` | S3 secret access key | - |
| `S3_KEY_SALT` | Salt for the per-project cache key the optimization studio sends to the NLP engine. Unset, no cache key is sent | - |
| `LANGWATCH_LOCAL_STORAGE_PATH` | Filesystem root used for stored-objects when no S3 is configured | `/var/lib/langwatch/objects` |

> [!NOTE]
>
> When running on AWS with IRSA (IAM Roles for Service Accounts), you don't need to set S3 access
> keys. The pod's service account assumes the IAM role automatically.

> [!WARNING]
>
> `LANGWATCH_LOCAL_STORAGE_PATH` is **single-replica only**. Multi-pod Kubernetes deployments must NOT rely on it: pods do not share a local filesystem, so a write from pod A is invisible to pod B and bytes vanish on every pod restart.
>
> Single-replica self-host installs (small footprints, hobbyist / air-gapped / pre-pilot deployments) can use it safely: the Helm chart refuses to render `localFilesystem.enabled=true` together with `replicaCount > 1` so the misconfiguration can't reach a cluster. Use `S3_BUCKET_NAME` (or the equivalent Helm `app.dataplane.enabled` toggle) for any horizontally-scaled deployment.

## Object Storage (Azure Blob)

Azure Blob Storage is an alternative to S3 for the same dataplane consumers (stored objects + dataset uploads). It is selected **only** by the explicit `STORED_OBJECTS_BACKEND=azure` toggle. Setting the `AZURE_BLOB_*` variables below by themselves has no effect. This is deliberate (issue #4133): deployment behavior must not depend on which env vars happen to be set, so a stray `AZURE_BLOB_ACCOUNT_KEY` left over from a decommissioned integration can never silently redirect writes.

Precedence, evaluated in this order: a per-project BYOC S3 bucket (if configured for that project) beats the azure toggle; the azure toggle beats `S3_BUCKET_NAME`; `S3_BUCKET_NAME` beats the local-filesystem fallback. This azure backend covers datasets too: the destination resolver is shared by both write paths.

| Variable | Description | Default |
| - | - | - |
| `STORED_OBJECTS_BACKEND` | Explicit write-destination toggle: `azure`, or `s3` (the default when unset). `s3` does not select a backend by itself. S3 is still chosen by `S3_BUCKET_NAME`, so the value only exists to say "not azure" | - |
| `AZURE_BLOB_ACCOUNT_NAME` | Azure Storage account name | - |
| `AZURE_BLOB_ACCOUNT_KEY` | Azure Storage account key. Required only by `AZURE_BLOB_AUTH_MODE=sharedKey` | - |
| `AZURE_BLOB_AUTH_MODE` | How to authenticate: `sharedKey`, `workloadIdentity`, `managedIdentity`, or `azureCli` | `sharedKey` |
| `AZURE_BLOB_AUTHORITY_HOST` | Entra authority host. Required for sovereign clouds | Public cloud |
| `AZURE_BLOB_TOKEN_AUDIENCE` | Token audience override. Sovereign clouds only | `https://storage.azure.com` |
| `AZURE_BLOB_CONTAINER` | Azure Blob container name | - |
| `AZURE_BLOB_ENDPOINT` | Blob endpoint base URL. Optional on the Azure public cloud; required for sovereign clouds (`*.usgovcloudapi.net`, `*.chinacloudapi.cn`), private endpoints, and the Azurite emulator | `https://{accountName}.blob.core.windows.net` |
| `OBJECT_RETENTION_CONFIRMED` | Set to `true` to confirm that the container has a lifecycle rule for every prefix in the [lifecycle table](https://langwatch.ai/docs/self-hosting/configuration/object-storage-lifecycle.md). Azure refuses oversized writes until it is set; the local filesystem always refuses them | `false` |
| `AZURE_BLOB_SPOOL_RETENTION_CONFIRMED` | Earlier, spool-only spelling of `OBJECT_RETENTION_CONFIRMED`. Still confirms when `OBJECT_RETENTION_CONFIRMED` is unset | `false` |

> [!WARNING]
>
> When `STORED_OBJECTS_BACKEND=azure` is set, `AZURE_BLOB_ACCOUNT_NAME` and `AZURE_BLOB_CONTAINER` are required, plus `AZURE_BLOB_ACCOUNT_KEY` when `AZURE_BLOB_AUTH_MODE` is `sharedKey` (the default). Under an identity mode the key must be absent (see below).
>
> An incomplete active Azure configuration fails with an error and never falls back to S3 or the local filesystem. Inactive Azure settings are validated only when an `azure-blob://` address is used, so they cannot break global or private-bucket S3 traffic.
>
> Set `AZURE_BLOB_ENDPOINT` (chart: `app.dataplane.providers.azureBlob.endpoint`) for sovereign clouds or private endpoints; it defaults to `https://{accountName}.blob.core.windows.net`. Per-project Azure BYOC is tracked separately (issue #6088).

### Migrating an existing installation

Do not switch `STORED_OBJECTS_BACKEND` before copying existing data. The migration task uses its own `OBJECT_STORAGE_MIGRATION_*` credentials, so source reads and destination writes do not depend on the app's active provider:

```bash theme={null}
pnpm --filter @langwatch/tasks task object-storage-migrate plan
pnpm --filter @langwatch/tasks task object-storage-migrate copy

# Stop all app and worker read/write traffic, drain GroupQueue, then:
OBJECT_STORAGE_MIGRATION_WRITES_PAUSED=1 \
OBJECT_STORAGE_MIGRATION_READS_PAUSED=1 \
  pnpm --filter @langwatch/tasks task object-storage-migrate finalize

# Switch STORED_OBJECTS_BACKEND and active credentials, then deploy while
# traffic remains paused.
pnpm --filter @langwatch/tasks task object-storage-migrate verify
```

`plan` is read-only. `copy` is resumable, verifies SHA-256 at the destination, and repairs a mismatched destination object. `finalize` refuses to run unless both reads and writes are explicitly paused, dataset uploads are idle, and GroupQueue has no pending, delayed, active, blocked, or staged durable references.

Reads must remain paused because finalization publishes destination storage addresses before the target-provider deployment is active. It publishes newer ClickHouse stored-object rows only after the final verified copy; it does not rewrite or delete history. No phase deletes source bytes.

After `finalize` succeeds, update `STORED_OBJECTS_BACKEND` and the active provider credentials while traffic remains paused, deploy that configuration, run `verify`, then resume traffic. A global migration excludes projects with private S3 buckets; they remain on their tenant-owned S3 destination.

**Keep the source provider's read credentials configured through the rollback window.** Finalization rewrites stored-object addresses to the destination scheme, so a rollback that reverts `STORED_OBJECTS_BACKEND` *and* removes the destination provider's credentials leaves every rewritten address unreadable. The bytes still exist on both providers (no phase deletes them), but the deployment can no longer serve that scheme. Concretely:

* **S3 → Azure**: after cutover, keep `S3_BUCKET_NAME` + S3 credentials configured (Helm: `app.dataplane.legacyS3ReadBucket`) so pre-migration `s3://` history stays readable. If you roll back to S3, keep the `AZURE_BLOB_*` settings configured (Helm: `app.dataplane.legacyAzureRead: true`) so the finalized `azure-blob://` addresses stay readable.
* **Azure → S3**: the mirror image. Keep `AZURE_BLOB_*` (Helm: `legacyAzureRead`) after cutover, and keep the S3 settings if you roll back.

A rollback with the flag set is fully non-destructive: reads resolve per address scheme, and a reverse migration can later re-home the bytes. Also note: the plan reports rows addressed by a scheme outside both providers (for example `file://` from a local-storage era). Those rows are out of the migration's scope, stay on their current addresses, and remain readable as long as that scheme's backend stays configured.

| Migration variable | Description |
| - | - |
| `OBJECT_STORAGE_MIGRATION_SOURCE_PROVIDER` | `s3` or `azure` |
| `OBJECT_STORAGE_MIGRATION_TARGET_PROVIDER` | The other provider |
| `OBJECT_STORAGE_MIGRATION_WRITES_PAUSED` | Must be `1` for `finalize` |
| `OBJECT_STORAGE_MIGRATION_READS_PAUSED` | Must be `1` for `finalize` |
| `OBJECT_STORAGE_MIGRATION_S3_BUCKET` | Global S3 source or destination bucket |
| `OBJECT_STORAGE_MIGRATION_S3_ENDPOINT` | Optional custom S3 endpoint |
| `OBJECT_STORAGE_MIGRATION_S3_REGION` | Optional S3 region. AWS uses its normal SDK region chain when omitted; custom endpoints default to `auto` |
| `OBJECT_STORAGE_MIGRATION_S3_ACCESS_KEY_ID` | Optional explicit migration access key |
| `OBJECT_STORAGE_MIGRATION_S3_SECRET_ACCESS_KEY` | Optional explicit migration secret key |
| `OBJECT_STORAGE_MIGRATION_S3_SESSION_TOKEN` | Optional temporary-session token |
| `OBJECT_STORAGE_MIGRATION_AZURE_ACCOUNT_NAME` | Azure source or destination account |
| `OBJECT_STORAGE_MIGRATION_AZURE_CONTAINER` | Azure source or destination container |
| `OBJECT_STORAGE_MIGRATION_AZURE_ENDPOINT` | Optional sovereign/private endpoint |
| `OBJECT_STORAGE_MIGRATION_AZURE_AUTH_MODE` | `sharedKey`, `workloadIdentity`, `managedIdentity`, or `azureCli` |
| `OBJECT_STORAGE_MIGRATION_AZURE_ACCOUNT_KEY` | Required only for `sharedKey` |
| `OBJECT_STORAGE_MIGRATION_AZURE_AUTHORITY_HOST` | Optional sovereign authority host |
| `OBJECT_STORAGE_MIGRATION_AZURE_TOKEN_AUDIENCE` | Optional sovereign token audience |

### Authenticating without an account key

`AZURE_BLOB_AUTH_MODE` selects how the app proves who it is. It defaults to `sharedKey`, so existing installs are unaffected.

Many organisations set `allowSharedKeyAccess=false` on the storage account, frequently enforced through Azure Policy. On such an account the shared-key mode does not degrade: **every request is refused**. Use one of the identity modes instead:

| Mode | Where it fits | Requires |
| - | - | - |
| `sharedKey` | Anywhere account keys are permitted | `AZURE_BLOB_ACCOUNT_KEY` |
| `workloadIdentity` | AKS. The enterprise path | A ServiceAccount annotated with the identity's client id, and the pod label `azure.workload.identity/use: "true"`. Both are chart values (see below) |
| `managedIdentity` | Azure VM, VMSS, App Service | The identity assigned to the machine |
| `azureCli` | A developer laptop | Being signed in with `az login` |

Whichever identity you use, grant it **Storage Blob Data Contributor** on the storage account or the specific container. The control-plane **Contributor** role does *not* grant data access. This is the single most common setup mistake, and it surfaces as a permission error rather than a sign-in error.

Three rules before you configure it:

* **There is no fallback.** If an identity mode is selected and the identity is missing or unauthorised, storage fails with an error and stays down until it is fixed. It never quietly reverts to an account key, because that would reverse your security decision at the moment you are least likely to notice.
* **An account key alongside an identity mode is rejected.** A key that would be silently ignored is worse than no key, since you would believe it was in use.
* **Bearer tokens require HTTPS.** A plaintext `AZURE_BLOB_ENDPOINT` is refused before any token is sent.

In the chart, set `app.dataplane.providers.azureBlob.authMode` and enable `global.serviceAccount` (`create: true` plus the `azure.workload.identity/client-id` annotation). The chart refuses to render `workloadIdentity` without a ServiceAccount, since the identity would have no ServiceAccount to bind to. Federated Kubernetes identity is supported on **AKS only**, because the token injection depends on the Azure workload-identity webhook. On clusters without it, use `managedIdentity` or `sharedKey`.

On AKS, three things must line up or the token exchange fails with `AADSTS70021` before any storage call is made. The app surfaces that code and this remedy in the error, but they are easier to get right up front:

* The **federated identity credential's issuer** must match the cluster's OIDC issuer exactly, *including the trailing slash*. Read it with `az aks show --query oidcIssuerProfile.issuerUrl`. If the cluster is ever recreated the issuer changes and every federated credential silently stops working.
* Its **subject** must be `system:serviceaccount:<namespace>:<service-account-name>`, matched case-exactly against the ServiceAccount the chart creates.
* Its **audience** must be `api://AzureADTokenExchange`.

The pod label and the ServiceAccount annotation are both emitted by the chart; the federated credential itself is configured on the Azure side and is the piece the chart cannot do for you.

The ServiceAccount and the workload-identity label are applied to the app and the workers only. Cron pods call the app over HTTP and never touch storage themselves, so giving them a Blob-capable token would grant every cron image storage access it has no use for.

The same identity must be available to any out-of-band job that writes bytes, such as the dataset backfill task; run it with the same ServiceAccount as the app and workers.

## Email

Notification emails (alerts, invites, scheduled reports, password resets) are sent through one gateway, selected with `EMAIL_PROVIDER`.

| Variable | Description | Default |
| - | - | - |
| `EMAIL_PROVIDER` | Gateway to send through: `sendgrid`, `ses`, `smtp` or `resend` | inferred |
| `EMAIL_DEFAULT_FROM` | Default "from" address | - |

See [Email](https://langwatch.ai/docs/self-hosting/configuration/email.md) for choosing a gateway and setting one up.

When `EMAIL_PROVIDER` is not set, the gateway is inferred from whichever credentials are present (`USE_AWS_SES` first, then `SENDGRID_API_KEY`), so existing installs keep working untouched. `smtp` and `resend` must always be named explicitly.

### SendGrid

| Variable | Description | Default |
| - | - | - |
| `SENDGRID_API_KEY` | SendGrid API key | - |

### AWS SES

| Variable | Description | Default |
| - | - | - |
| `USE_AWS_SES` | Set to `true` to send through SES | `false` |
| `AWS_REGION` | AWS region for SES | - |
| `AWS_SES_ENDPOINT` | Override the SES endpoint, for a VPC endpoint or an internal relay | public regional endpoint |

Credentials come from the standard AWS chain (instance role, IRSA, or `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`).

### SMTP

Use this for an internal relay, Azure Communication Services, Office 365, Google Workspace, Postmark, Mailgun, Brevo, or any other gateway that speaks SMTP.

| Variable | Description | Default |
| - | - | - |
| `SMTP_URL` | Full connection URL, e.g. `smtps://user:pass@relay.internal:465`. Takes precedence over the settings below | - |
| `SMTP_HOST` | Relay hostname | - |
| `SMTP_PORT` | Relay port | `587` |
| `SMTP_SECURE` | `true` for implicit TLS, `false` for STARTTLS | `true` on port 465, otherwise `false` |
| `SMTP_USER` | Username. Omit entirely for an unauthenticated internal relay | - |
| `SMTP_PASSWORD` | Password | - |

### Resend

| Variable | Description | Default |
| - | - | - |
| `RESEND_API_KEY` | Resend API key | - |

### Sending through an outbound proxy

Deployments whose only egress is a corporate HTTP proxy can set the standard `HTTPS_PROXY` (or `HTTP_PROXY`) and `NO_PROXY` variables. The `ses`, `resend` and `sendgrid` gateways all route their API calls through it; `NO_PROXY` entries are honoured, including leading-dot domain suffixes and `*`. Single sign-on setup uses the same variables: see [Single sign-on](https://langwatch.ai/docs/self-hosting/configuration/sso.md#behind-a-corporate-proxy).

The `smtp` gateway ignores these variables on purpose, because an SMTP relay is normally an internal host that must be reached directly. Point it at your relay's real address, or use a vendor's SMTP endpoint if that is what the proxy allows out.

> [!NOTE]
>
> Neither the AWS SDK nor Node's HTTP client reads `HTTPS_PROXY` on its own, so LangWatch attaches
> the proxy to the SES and Resend clients itself. SendGrid's client is axios-based and reads the
> variables directly. Without this wiring, a proxied deployment fails with a `read ETIMEDOUT`
> reaching the provider.

## PII Redaction (Google DLP)

Model providers for evaluators, including Azure OpenAI, are configured per project in **Settings > Model Providers**, not through environment variables.

| Variable | Description | Default |
| - | - | - |
| `GOOGLE_APPLICATION_CREDENTIALS` | The contents of a Google service account JSON key (the JSON itself, not a file path), with a `project_id`. Enables Google DLP PII redaction | - |
| `LANGWATCH_DISABLE_GOOGLE_DLP` | `true` turns Google DLP redaction off even when credentials are set | `false` |

## Voice Agents

`VOICE_CALL_MAX_SECONDS` and `VOICE_RUNS_MAX_CONCURRENT` shape [voice agent](https://langwatch.ai/docs/agent-testing/voice-agents.md) runs, which need `FEATURE_FLAG_FORCE_ENABLE=release_voice_agents_enabled`. The other three act on every worker boot, with the flag on or off: each worker listens on `VOICE_WS_PORT`. On a self-hosted production install it opens a cloudflared quick tunnel only when `VOICE_TUNNEL=true` and `VOICE_PUBLIC_BASE_URL` is unset.

| Variable | Description | Default |
| - | - | - |
| `VOICE_CALL_MAX_SECONDS` | Longest a voice call can last, in seconds. A scenario call is cut at this limit and marked "Cut at the call limit". Browser calls (**Talk to it**, **Call it myself**) are cut at the same limit. | `300` |
| `VOICE_RUNS_MAX_CONCURRENT` | Voice runs at a time, per project, per worker process. More voice runs wait in the queue. Text runs are not affected. | `2` |
| `VOICE_PUBLIC_BASE_URL` | Public https origin of the voice worker, for Twilio phone calls to reach it. When set, it always wins over the tunnel. | - |
| `VOICE_TUNNEL` | Set to `true` to have the worker open a cloudflared quick tunnel at boot and use its URL when `VOICE_PUBLIC_BASE_URL` is unset. The tunnel calls Cloudflare. Phone runs fail when `VOICE_PUBLIC_BASE_URL` is unset and no tunnel is open. | `false` when `NODE_ENV=production` (a self-hosted install). Unset, it is on for LangWatch Cloud and when `NODE_ENV` is not `production`, for example local development |
| `VOICE_WS_PORT` | Port the voice worker listens on for phone audio. Route your public origin to it. | `3300` |

## Upgrade

The app and the workers never migrate on startup: `pnpm task upgrade` does, before they start, and they refuse to start until it has run. See [Run the upgrade](https://langwatch.ai/docs/self-hosting/upgrade.md#run-the-upgrade).

| Variable | Description | Default |
| - | - | - |
| `SKIP_LWQL_PROVISION` | `1` or `true` makes `pnpm task upgrade` skip LangWatchQL provisioning | `false` |
| `CLICKHOUSE_MIGRATE_WAIT_SECONDS` | Listed under [Core Configuration](#core-configuration) | `180` |
| `CLICKHOUSE_CLUSTER` | Cluster name. When set, the upgrade creates Replicated engines and replicates DDL across nodes | - |
| `CLICKHOUSE_COLD_STORAGE_ENABLED` | `true` makes the upgrade's reconcile phase write TTLs that move aged data to the cold volume | `false` |
| `CLICKHOUSE_COLD_STORAGE_DEFAULT_TTL_DAYS` | Hot days for every table without its own `CLICKHOUSE_COLD_STORAGE_<TABLE>_TTL_DAYS` | each table's built-in default |
| `BASE_HOST` | The upgrade's last log line reads it to say where the UI is | - |
| `BUILD_TIME` | Set while building an image: the upgrade applies no ClickHouse migration. Leave unset at runtime | unset |

Exit codes, outcome codes and the log lines are in [Upgrade Guide](https://langwatch.ai/docs/self-hosting/upgrade.md#exit-codes-and-what-to-do).

## Cloud admin

| Variable | Description | Default |
| - | - | - |
| `LANGWATCH_CLOUD_OPS` | Turns on Cloud admin in Ops (licence and instance registries). For LangWatch's own cloud only; also needs `LANGWATCH_LICENSE_PRIVATE_KEY` matching the release's public key, or boot is refused. Leave unset on every other deployment. | `false` |

## Telemetry

| Variable | Description | Default |
| - | - | - |
| `DISABLE_USAGE_STATS` | Stop the daily usage report. See [Data and telemetry](https://langwatch.ai/docs/self-hosting/data-and-telemetry.md) for what it carries and the two smaller switches | `false` |
| `OTEL_METRICS_EXPORTER` | `otlp` pushes metrics to `OTEL_EXPORTER_OTLP_ENDPOINT`; `prometheus` serves a scrape listener on `OTEL_EXPORTER_PROMETHEUS_PORT` (default `9464`) instead. The Helm chart sets `prometheus` when `app.telemetry.metrics.enabled` is true | `otlp` |
| `METRICS_API_KEY` | Bearer token a Prometheus scrape must send. The metrics endpoint is only mounted when it is set | - |
| `OTEL_NODE_ENABLED_INSTRUMENTATIONS` | Comma-separated library patches the app and worker load at start, replacing the default list. Known names: `aws-sdk`, `openai`, `ioredis`. Add `ioredis` to trace Redis commands (high volume next to a job queue) | `aws-sdk,openai` |
| `OTEL_NODE_DISABLED_INSTRUMENTATIONS` | Comma-separated library patches to turn off, applied after the enabled list. Set `aws-sdk,openai` to load none | - |

## Langy (optional)

Langy, the automated AI engineer, runs as a separate agent pod that the chart deploys by default (`langyagent.chartManaged: true`). It uses a shared `LANGY_INTERNAL_SECRET` (plus optional `GITHUB_LANGY_*` and mirror-lane vars). Those are documented on their own page.

> [!NOTE]
>
> See [Langy environment variables](https://langwatch.ai/docs/self-hosting/langy/environment-variables.md) for the full
> `LANGY_*` and `GITHUB_LANGY_*` reference, and [Setup](https://langwatch.ai/docs/self-hosting/langy/setup.md) for the
> `langyagent.chartManaged` toggle and the `LANGY_INTERNAL_SECRET` secret.

## Helm Chart Mapping

When using the Helm chart, configuration is set in `values.yaml` rather than environment variables directly. Here's how key values map:

| Helm Value | Environment Variable |
| - | - |
| `app.http.baseHost` | `BASE_HOST` |
| `app.http.publicUrl` | `NEXTAUTH_URL` |
| `app.http.langwatchEndpoint` | `LANGWATCH_ENDPOINT` |
| `app.license.key.value` | `LANGWATCH_LICENSE_KEY` |
| `app.nextAuth.provider` | `AUTH_PROVIDER` |
| `app.signUp.mode` | `SIGN_UP_MODE` |
| `app.signUp.allowedDomains` | `SIGN_UP_ALLOWED_DOMAINS` (joined with commas) |
| `app.nextAuth.secret.value` | `NEXTAUTH_SECRET` |
| `app.credentialsEncryptionKey.value` | `CREDENTIALS_SECRET` |
| `app.credentialsEncryptionKey.previous.value` | `CREDENTIALS_SECRET_PREVIOUS`, only when set. With `secrets.existingSecret`, name its key in `secrets.secretKeys.credentialsEncryptionKeyPrevious` instead |
| `app.email.provider` | `EMAIL_PROVIDER` |
| `app.email.providers.sendgrid.apiKey.value` | `SENDGRID_API_KEY` |
| `app.email.providers.ses.region` | `AWS_REGION` (with `USE_AWS_SES=true`) |
| `app.email.providers.ses.endpoint` | `AWS_SES_ENDPOINT` |
| `app.email.providers.smtp.url.value` | `SMTP_URL` |
| `app.email.providers.smtp.host` | `SMTP_HOST` |
| `app.email.providers.smtp.port` | `SMTP_PORT` |
| `app.email.providers.smtp.secure` | `SMTP_SECURE` |
| `app.email.providers.smtp.user` | `SMTP_USER` |
| `app.email.providers.smtp.password.value` | `SMTP_PASSWORD` |
| `app.email.providers.resend.apiKey.value` | `RESEND_API_KEY` |
| `app.evaluators.google.credentials.value` | `GOOGLE_APPLICATION_CREDENTIALS` when `app.evaluators.google.enabled` is on |
| `app.telemetry.usage.enabled` | Inverse of `DISABLE_USAGE_STATS` |
| `app.telemetry.metrics.apiKey.value` | `METRICS_API_KEY`, with `OTEL_METRICS_EXPORTER=prometheus` on port `9464`, when `app.telemetry.metrics.enabled` is on |
| `app.dataplane.enabled` | `STORED_OBJECTS_BACKEND=s3` (provider: `awsS3`) or `STORED_OBJECTS_BACKEND=azure` (provider: `azureBlob`) |
| `app.dataplane.bucket` | `S3_BUCKET_NAME` |
| `app.dataplane.providers.azureBlob.accountName.value` | `AZURE_BLOB_ACCOUNT_NAME` |
| `app.dataplane.providers.azureBlob.accountKey.value` | `AZURE_BLOB_ACCOUNT_KEY` (sharedKey mode only) |
| `app.dataplane.providers.azureBlob.authMode` | `AZURE_BLOB_AUTH_MODE` |
| `global.serviceAccount.create` / `.name` / `.annotations` | ServiceAccount the cloud identity binds to (also enables AWS IRSA) |
| `app.dataplane.providers.azureBlob.container.value` | `AZURE_BLOB_CONTAINER` |
| `app.dataplane.providers.azureBlob.endpoint.value` | `AZURE_BLOB_ENDPOINT` (sovereign clouds, private endpoints, emulator) |
| `app.dataplane.objectRetentionConfirmed` | `OBJECT_RETENTION_CONFIRMED`. Confirms the lifecycle rules in the [lifecycle table](https://langwatch.ai/docs/self-hosting/configuration/object-storage-lifecycle.md) |
| `app.dataplane.providers.azureBlob.spoolRetentionConfirmed` | Earlier spelling; either value sets `OBJECT_RETENTION_CONFIRMED=true` |
| `app.dataplane.providers.azureBlob.authorityHost.value` | `AZURE_BLOB_AUTHORITY_HOST`. Required alongside a sovereign endpoint in a token-based mode |
| `app.dataplane.providers.azureBlob.tokenAudience.value` | `AZURE_BLOB_TOKEN_AUDIENCE`. Sovereign storage audience override |
| `app.dataplane.legacyS3ReadBucket` | Keeps persisted `s3://` stored-object URIs and true legacy bucket/key consumers readable after moving writes to Azure. It does not route provider-derived dataset chunks or GroupQueue durable payloads; run the migration and drain GroupQueue before switching |
| `app.dataplane.legacyAzureRead` | Keeps `AZURE_BLOB_*` settings emitted after moving writes to S3 so persisted `azure-blob://` stored objects remain readable. It is not a dataset migration mechanism: dataset chunks require a verified copy before switching providers |
| `postgresql.external.connectionString.value` | `DATABASE_URL` |
| `redis.external.connectionString.value` | `REDIS_URL` |
| `workers.enabled` | Not an environment variable. It toggles whether the separate workers Deployment is created at all. There is no in-process worker mode to fall back to. |
| `langwatch_nlp.secrets.internalSecretKey` | Key of the app Secret the app, the workers and the NLP service read `LANGWATCH_NLP_INTERNAL_SECRET` from |

> [!TIP]
>
> For production, use `secretKeyRef` instead of inline values. This references a Kubernetes Secret:
>
> ```yaml theme={null}
> app:
>   credentialsEncryptionKey:
>     secretKeyRef:
>       name: langwatch-secrets
>       key: credentialsEncryptionKey
> ```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.