dograh/deploy/helm/dograh/values.yaml
prabhatlepton 58cc9c8b1c
fix(web): honor X-Forwarded-Proto in uvicorn so request.url is https behind a reverse proxy (#515)
* fix(web): honor X-Forwarded-Proto in uvicorn so request.url is https behind a reverse proxy

## Problem

When Dograh runs behind a TLS-terminating reverse proxy (Cloudflare →
Traefik in Kubernetes, nginx in the docker-compose install), the inside
of the cluster/host is plain HTTP. Uvicorn defaults to trusting
`scope["scheme"]` from the socket, so `request.url.scheme` reads `http`
even though the client dialed `https`.

That breaks any code path that hashes or echoes the request URL back to
the caller. Concrete symptom seen in production: **Vobiz inbound webhook
signatures fail with "signature validation failed for vobiz"** because
Vobiz computes HMAC over the URL it dialed (`https://.../inbound/run`)
while Dograh recomputes it as `http://...`.  Log excerpt from the
failing call:

```
WARNING | provider.py | Vobiz webhook signature mismatch.
         Expected: daOpAZPm..., Got: 1+eW/RxE...
WARNING | telephony.py | /inbound/run: signature validation failed for vobiz
```

Twilio, Plivo and any other provider that signs over the callback URL
have the same failure mode when Dograh is deployed behind a proxy.

## Fix

Start uvicorn with `--proxy-headers --forwarded-allow-ips="*"` in
`scripts/run_web.sh`. Uvicorn rewrites `scope["scheme"]` and client
address from `X-Forwarded-Proto` / `X-Forwarded-For` when the request
originates from a trusted upstream — Traefik and Cloudflare set both
correctly, so `request.url.scheme == "https"` inside the app once again
and provider signature checks pass.

Verified end-to-end on a production k3s install (Traefik + Cloudflare
edge → dograh-web pod) — after the change, the very next Vobiz inbound
webhook validated successfully and the call connected past the previous
11-second signature-failure hangup.

* address review: let operators narrow FORWARDED_ALLOW_IPS

Both bot reviewers on #515 flagged `--forwarded-allow-ips="*"` as a
defence-in-depth concern: if uvicorn is directly reachable from an
untrusted network (bypassing the proxy), any client can spoof
`X-Forwarded-Proto` / `X-Forwarded-For`, and uvicorn will rewrite
`request.client` / `request.url` from those attacker-controlled headers.

Fix: consume `FORWARDED_ALLOW_IPS` from the environment (uvicorn already
recognizes this env var; see `deploy/hostinger/docker-compose.yaml:179`
for the existing precedent). Default stays `"*"` so the behavior of the
original fix is preserved for the standard docker-compose / helm layouts
where the app pod is only reachable via the proxy Service. Operators
who terminate uvicorn on a host that's also reachable directly can
narrow it to the proxy CIDR:

  FORWARDED_ALLOW_IPS="10.42.0.0/16" ./scripts/run_web.sh

* address review: declare FORWARDED_ALLOW_IPS in the helm chart, not the script

uvicorn already enables proxy-header handling by default and falls back to
the FORWARDED_ALLOW_IPS env var when --forwarded-allow-ips is absent, so the
CLI flags were redundant and the script-level "*" default hid a
security-relevant trust decision away from operators. Drop the flags, keep
run_web.sh deployment-agnostic, and declare the env var where the other
deployment config lives — web.forwardedAllowIps in values.yaml (default "*",
narrowable to a proxy CIDR) — mirroring how docker-compose already sets it
on the api service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* simplify run_web.sh comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: prabhat pankaj <prabhatiitbhu@gmail.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:26:45 +05:30

581 lines
20 KiB
YAML

# Dograh Helm chart — default values.
#
# Conventions:
# - "mode" fields are enums; see values.schema.json for allowed values.
# - Anything sensitive (passwords, tokens, signing keys) is split into the
# `secrets:` section and rendered as a Kubernetes Secret. Non-sensitive
# config lives in `config:` and renders as a ConfigMap.
# - The chart never ships real defaults for credentials. Operators must
# override `secrets.*` (or supply an existing Secret name).
# -----------------------------------------------------------------------------
# Global image config — applied to web, workers, ariManager, campaignOrchestrator
# -----------------------------------------------------------------------------
image:
registry: docker.io
repository: dograhai/dograh-api
tag: latest
pullPolicy: IfNotPresent
imagePullSecrets: []
# - name: regcred
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
name: ""
annotations: {}
# -----------------------------------------------------------------------------
# Stateful dependency modes.
#
# database.mode:
# internal — bundled Postgres manifest (pgvector image; see `postgresql` below)
# external — operator supplies DATABASE_URL via secrets.databaseUrl
# redis.mode:
# internal — bundled Redis manifest (see `redisinternal` below)
# external — operator supplies REDIS_URL via secrets.redisUrl
# storage.mode:
# internalMinio — bundled MinIO manifest (see `minio` below)
# externalMinio — operator supplies a MinIO-compatible endpoint + creds
# s3 — sets ENABLE_AWS_S3=true; uses AWS S3
# exposure.mode:
# gatewayApi — renders Gateway + HTTPRoute (gateway.networking.k8s.io/v1)
# ingress — renders Ingress resources (networking.k8s.io/v1)
# -----------------------------------------------------------------------------
database:
mode: internal
# For external mode, secrets.databaseUrl must be set.
redis:
mode: internal
# For external mode, secrets.redisUrl must be set.
storage:
mode: internalMinio
# For externalMinio mode, set externalMinio.endpoint + secrets.minioAccessKey
# + secrets.minioSecretKey.
externalMinio:
endpoint: "" # e.g. minio.example.com
publicEndpoint: "" # browser-visible URL
secure: false
bucket: voice-audio
# For s3 mode, set s3.region. AWS credentials are picked up from the pod's
# IAM role (IRSA recommended) or from secrets.awsAccessKeyId + secrets.awsSecretAccessKey.
s3:
region: us-east-1
bucket: voice-audio
publicEndpoint: "" # e.g. https://s3.amazonaws.com
exposure:
# Default is `ingress` because it works out-of-the-box on any cluster
# without requiring Gateway API CRDs. Production deployments should
# prefer `gatewayApi` per HELM_DEPLOYMENT_PLAN.md — switch the mode
# and supply gatewayClassName.
mode: ingress
# Gateway API config (when mode=gatewayApi).
gatewayApi:
# Set to false to skip rendering the Gateway resource and instead
# attach HTTPRoutes to a pre-existing Gateway (parentRef.name below).
createGateway: true
gatewayClassName: "" # required when createGateway=true (e.g. "istio", "envoy-gateway", "aws-alb")
listenerHostname: "" # optional SNI hostname for the listener; empty = wildcard
# Reference an existing Gateway instead of creating one.
# Ignored when createGateway=true.
parentRefs:
- name: dograh
namespace: "" # empty = same namespace as the release
# Ingress config (when mode=ingress).
ingress:
className: "" # e.g. "nginx", "alb"
annotations: {}
# Hostname for the API/UI. UI is served at / and API under /api/.
# MinIO browser-visible path uses the same hostname under /voice-audio/.
host: "" # e.g. dograh.example.com
tls:
enabled: false
secretName: "" # operator-managed TLS secret in the release namespace
# -----------------------------------------------------------------------------
# Non-sensitive runtime config — rendered into a ConfigMap and injected via
# envFrom on every backend pod. Sensitive values live under `secrets:` below.
# -----------------------------------------------------------------------------
config:
environment: production
logLevel: INFO
backendApiEndpoint: "" # public URL the browser uses to reach the API; auto-derived from exposure.host if empty in NOTES
minioBucket: voice-audio
minioEndpoint: "" # internal cluster endpoint (auto-set when internalMinio)
minioPublicEndpoint: "" # browser-visible endpoint (auto-set when ingress/gateway path exposes MinIO)
minioSecure: false
enableAwsS3: false
enableTelemetry: true
posthogHost: https://us.i.posthog.com
posthogApiKey: phc_ItizB1dP6yv7ZYobbcqrpxTdbomDA8hJFSEmAMdYvIr
forceTurnRelay: false
turnHost: "" # public hostname/IP of coturn (the LoadBalancer address)
fastapiWorkers: 1 # informational only; web tier scales by pod, not in-pod workers
enableSignup: true # set false to 403 the /api/v1/auth/signup endpoint (invite-only lockdown)
# -----------------------------------------------------------------------------
# Secrets — rendered into a Kubernetes Secret unless secrets.existingSecret is
# set. NEVER commit real values here; override via -f overrides.yaml or
# --set-string at install time.
# -----------------------------------------------------------------------------
secrets:
# If set, the chart skips rendering its own Secret and assumes this Secret
# already exists in the release namespace with all keys below.
existingSecret: ""
# Required when database.mode=external.
databaseUrl: "" # e.g. postgresql+asyncpg://user:pass@host:5432/dograh
# Required when redis.mode=external.
redisUrl: "" # e.g. redis://:pass@host:6379
# MinIO / S3 credentials.
minioAccessKey: ""
minioSecretKey: ""
awsAccessKeyId: "" # only used when storage.mode=s3 and not using IRSA
awsSecretAccessKey: ""
# JWT signing key for the OSS auth path. MUST be overridden in production.
ossJwtSecret: "ChangeMeInProduction"
# TURN REST API shared secret (matches coturn.staticAuthSecret below).
turnSecret: ""
# Optional Langfuse tracing.
langfuseSecretKey: ""
langfusePublicKey: ""
langfuseHost: ""
# -----------------------------------------------------------------------------
# Web tier (FastAPI + WebSocket signaling)
# -----------------------------------------------------------------------------
web:
replicaCount: 2
port: 8000
# Peers uvicorn trusts X-Forwarded-Proto / X-Forwarded-For from (exported
# as FORWARDED_ALLOW_IPS). The ingress proxy reaches the pod from a
# pod-network IP — not loopback — so uvicorn's 127.0.0.1 default ignores
# the headers, request.url reads back as http://, and telephony providers
# that sign their webhook URL (Vobiz, Twilio, Plivo) fail signature
# validation. "*" trusts every peer, which is fine while only the ingress
# can reach the pod; narrow to your proxy/pod CIDR (e.g. "10.42.0.0/16")
# if the pod is reachable from untrusted networks.
forwardedAllowIps: "*"
# Long-lived signaling WebSockets keep per-connection state in process
# memory (api/routes/webrtc_signaling.py). A naive pod restart drops every
# in-flight call. The two settings below give the gateway time to stop
# routing new connections to a terminating pod and give in-flight calls
# time to finish.
terminationGracePeriodSeconds: 600
# preStop sleep: long enough for the load balancer to observe the pod going
# NotReady and stop sending new connections. 15s is conservative for most
# controllers (gateway/nginx/ALB).
preStopSleepSeconds: 15
resources:
# These are conservative starting numbers. Tune to your workload —
# WebRTC signaling is mostly idle but bursty during call setup.
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
# Distinct probes so the pod can fail readiness during drain without being
# killed for liveness. liveness has a longer threshold (process is alive)
# while readiness flips quickly (stop receiving new connections).
livenessProbe:
httpGet:
path: /api/v1/health
port: 8000
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /api/v1/health
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
service:
type: ClusterIP
port: 8000
annotations: {}
pdb:
enabled: true
minAvailable: 1
podAnnotations: {}
nodeSelector: {}
tolerations: []
# Recommend spreading web pods across zones / nodes.
topologySpreadConstraints: []
affinity: {}
# -----------------------------------------------------------------------------
# ARQ background workers
# -----------------------------------------------------------------------------
workers:
replicaCount: 1
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
# exec probe — workers have no HTTP endpoint.
livenessProbe:
exec:
# The entrypoint exec's the worker, so it runs as PID 1; grep its cmdline.
# Avoids pgrep/procps, which isn't in the slim runtime image. Matching a
# single argv token (no spaces) — argv is NUL-separated in /proc.
command: ["sh", "-c", "grep -qa api.tasks.arq.WorkerSettings /proc/1/cmdline"]
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# -----------------------------------------------------------------------------
# ARI manager — TELEPHONY SINGLETON
#
# Maintains an outbound WebSocket to Asterisk and is the canonical receiver of
# ARI events. Running >1 replica produces duplicate event handling. The chart
# hard-codes replicas:1 and strategy:Recreate; there is NO replica knob here
# on purpose. Add proper leader election before relaxing this.
# -----------------------------------------------------------------------------
ariManager:
enabled: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
exec:
# PID 1 cmdline check (procps-free; see workers.livenessProbe).
command: ["sh", "-c", "grep -qa api.services.telephony.ari_manager /proc/1/cmdline"]
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# -----------------------------------------------------------------------------
# Campaign orchestrator — CAMPAIGN SINGLETON
#
# Uses in-memory deduplication locks (api/services/campaign/campaign_orchestrator.py
# `_processing_locks`). Running >1 replica would silently break scheduling.
# Same singleton rules as ariManager: no replica knob, Recreate strategy.
# -----------------------------------------------------------------------------
campaignOrchestrator:
enabled: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
exec:
# PID 1 cmdline check (procps-free; see workers.livenessProbe).
command: ["sh", "-c", "grep -qa api.services.campaign.campaign_orchestrator /proc/1/cmdline"]
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# -----------------------------------------------------------------------------
# Next.js UI
# -----------------------------------------------------------------------------
ui:
enabled: true
replicaCount: 2
image:
registry: docker.io
repository: dograhai/dograh-ui
tag: latest
pullPolicy: IfNotPresent
port: 3010
# Server-side (SSR) URL. Defaults to the in-cluster web Service.
backendUrl: "" # auto-set in template when empty
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /
port: 3010
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 3010
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 2
service:
type: ClusterIP
port: 3010
pdb:
enabled: true
minAvailable: 1
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# -----------------------------------------------------------------------------
# coturn — TURN media relay
# -----------------------------------------------------------------------------
coturn:
enabled: true
image:
registry: docker.io
repository: coturn/coturn
tag: "4.8.0"
pullPolicy: IfNotPresent
# External IP advertised by coturn for NAT traversal. This is the
# LoadBalancer IP of the coturn Service. There is a chicken-and-egg here:
# the LB IP may not be known until after install. See NOTES.txt for the
# supported workflow (install with placeholder, kubectl get svc, helm
# upgrade --set coturn.externalIp=<IP>).
externalIp: ""
realm: dograh.com
# Coturn uses TURN REST API authentication (HMAC-SHA1). The secret here
# MUST match secrets.turnSecret — the chart will warn at install time if
# they diverge.
staticAuthSecretFromSecretsKey: turnSecret
# Relay port range. AWS NLB has a default quota of 50 listeners per LB,
# so the default 49 ports (49152-49200) sits just inside the limit.
# Increasing this requires either a higher NLB listener quota or
# additional TURN deployments.
relayPortRange:
min: 49152
max: 49200
# Standard TURN ports.
ports:
plain: 3478
tls: 5349
# TLS for turns:// — NOT WIRED IN v1. The original docker-compose exposes
# 5349 but does not configure cert paths. v1 scopes to plain TURN over
# UDP/TCP. See README.md "Open TODOs".
tls:
enabled: false
service:
type: LoadBalancer
annotations: {}
# externalTrafficPolicy: Local preserves the client IP, which TURN auth
# benefits from. Some LBs need this set to "Cluster" to be reachable.
externalTrafficPolicy: Local
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "2"
memory: 1Gi
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# -----------------------------------------------------------------------------
# Migration Job
# -----------------------------------------------------------------------------
migrate:
# Run alembic upgrade head as a Helm hook: post-install (so the bundled
# Postgres exists first) and pre-upgrade (so migrations land before new code).
enabled: true
# Hard cap on how long a migration may run. A failed/exceeded migration
# rolls back the install/upgrade because backoffLimit is 0.
activeDeadlineSeconds: 600
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
# -----------------------------------------------------------------------------
# Horizontal Pod Autoscaling.
#
# All tiers default to `enabled: false`; opt in per tier. Requires
# metrics-server in the cluster. HPA reads Deployment `replicas` on first sync
# and then owns it, so `web.replicaCount` etc. are ignored once the
# corresponding autoscaling block is enabled — treat `minReplicas` as the new
# floor. Enabling a tier's HPA also removes `replicas` from its Deployment, so
# on the first upgrade after enabling, the tier briefly resets to 1 pod until
# the HPA reconciles — enable during a quiet window.
#
# WARNING (web): CPU/memory is a poor signal for WebRTC signaling workloads.
# WebSockets are long-lived, low-CPU, and steady-memory; CPU will look flat
# while you saturate per-pod connection limits. Replace this with a custom
# metric (active WS connections, active calls) once one is exposed.
#
# Workers (ARQ): jobs are mostly IO-bound (webhook delivery, embeddings, LLM
# calls), so CPU can stay flat while the queue backs up. CPU HPA is a coarse
# stopgap; the long-term plan is queue-depth / active-call driven scaling via
# KEDA or a custom metric (see README TODOs). If an external scaler (e.g. a
# KEDA ScaledObject) owns the worker Deployment, keep this block disabled so
# the chart does not render a competing HPA.
#
# UI (Next.js SSR) correlates reasonably with CPU; resource-metric HPA is a
# fine choice there.
# -----------------------------------------------------------------------------
autoscaling:
web:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
workers:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 70
# NOTE: memory HPA disabled by default. Idle Python (ARQ worker) at the
# chart's default 256Mi request sits near the 80% target on cold start,
# so HPA would scale to maxReplicas with no real load. Enable only after
# sizing your workload's steady-state memory well below the target.
targetMemoryUtilizationPercentage: null
ui:
enabled: false
# Floor of 2 matches ui.replicaCount and keeps ui.pdb (minAvailable: 1)
# satisfiable during node drains. Single-node installs that drop this to 1
# should also disable ui.pdb (see examples/values-k3s-prod.yaml).
minReplicas: 2
maxReplicas: 5
targetCPUUtilizationPercentage: 70
# Same reason as workers — idle Next.js SSR sits close to the 256Mi request.
targetMemoryUtilizationPercentage: null
# -----------------------------------------------------------------------------
# Bundled stateful deps for the internal/all-in-one modes. These are plain
# in-chart manifests (templates/internal-*.yaml) on official upstream images —
# not subcharts. Each renders only in its internal mode:
# postgresql -> database.mode == internal
# redisinternal-> redis.mode == internal
# minio -> storage.mode == internalMinio
# For production, use the external/managed modes instead (see database/redis/
# storage above).
# -----------------------------------------------------------------------------
postgresql:
# Official pgvector image = upstream Postgres + the `vector` extension the app
# migrations require. Keep the tag on a supported Postgres major.
image:
registry: docker.io
repository: pgvector/pgvector
tag: pg17
pullPolicy: IfNotPresent
auth:
username: dograh # also the initdb superuser (needed for CREATE EXTENSION)
password: "" # auto-generated and persisted across upgrades if empty
database: dograh
persistence:
size: 8Gi
storageClass: "" # "" = cluster default StorageClass
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: "1", memory: 1Gi }
redisinternal:
# Official upstream Redis. Single node, password-protected, AOF persistence.
# (Key kept as `redisinternal` so it doesn't collide with `redis.mode` above.)
image:
registry: docker.io
repository: redis
tag: 7.4-alpine
pullPolicy: IfNotPresent
auth:
password: "" # auto-generated and persisted across upgrades if empty
persistence:
size: 8Gi
storageClass: ""
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 500m, memory: 256Mi }
minio:
# Official upstream MinIO. Root creds are shared with the app (single source of
# truth) so they can't drift; the app creates its bucket on first use.
image:
registry: docker.io
repository: minio/minio
tag: RELEASE.2025-04-22T22-12-26Z
pullPolicy: IfNotPresent
auth:
rootUser: minioadmin
rootPassword: "" # auto-generated and persisted across upgrades if empty
persistence:
size: 20Gi
storageClass: ""
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: "1", memory: 1Gi }