Kubernetes Deployment
Deploy DVARA on Kubernetes with the official Helm chart.
Prerequisites
- Kubernetes 1.28+
- Helm 3.x
kubectlconfigured for your cluster- A reachable PostgreSQL 14+ instance (the chart does NOT bundle PostgreSQL — see Common Deployment Patterns below for how to wire it)
- A DVARA license envelope (
DVARA-…prefix), optional. Everything boots without one, in the Development posture; a license activates the MCP and A2A planes. Refuse to start without it. Enterprise also needs the private-ee/ proxy images (see Editions & container images) — no operator-flippable license bypass.
Quick Start
Mint the required secrets first — every DVARA install needs five chart-managed secrets:
# 5 chart-managed secrets
export DVARA_LICENSE_KEY="DVARA-…" # from your trial / contract email
export ACTUATOR_API_KEY=$(openssl rand -base64 32) # operator Bearer for /actuator/gateway-status
export METRICS_API_KEY=$(openssl rand -base64 32) # DISTINCT Bearer for /actuator/prometheus
export ENCRYPTION_PASSWORD=$(openssl rand -base64 32) # AES-256-GCM key for ENCRYPTED-mode credentials
export AUDIT_HMAC=$(openssl rand -base64 32) # signs audit-chain envelopes
From OCI Registry (recommended)
Pre-built images and the Helm chart are published to GitHub Container Registry:
helm install dvara oci://ghcr.io/dvarahq/charts/dvara \
--version 1.7.0 \
--set secrets.enterpriseLicenseKey="$DVARA_LICENSE_KEY" \
--set secrets.llmGatewayServerApiKey="$ACTUATOR_API_KEY" \
--set secrets.gatewayMetricsApiKey="$METRICS_API_KEY" \
--set secrets.gatewayEncryptionMasterPassword="$ENCRYPTION_PASSWORD" \
--set secrets.auditHmacSecret="$AUDIT_HMAC" \
--set secrets.mockProviderEnabled=true
# Wait for pods to be ready
kubectl rollout status deployment/dvara-server
kubectl rollout status deployment/dvara-flightdeck
# Verify
kubectl port-forward svc/dvara-server 8080:8080 &
curl http://localhost:8080/actuator/health
{"status": "UP", "groups": ["liveness", "readiness"]}
That is the whole body, and it is deliberate: management.endpoint.health.show-details is
when-authorized, so an anonymous caller — which is what a probe and a curl both are — gets the
verdict and nothing else. Per-indicator detail would expose cluster and pool internals. If you need
it, authenticate; if you are debugging a pod that will not become ready, use
/actuator/health/readiness and the pod's logs rather than expecting more here.
From Local Chart
Same secret arguments, pointing at a local chart directory:
helm install dvara charts/dvara/ \
--version 1.7.0 \
--set secrets.enterpriseLicenseKey="$DVARA_LICENSE_KEY" \
--set secrets.llmGatewayServerApiKey="$ACTUATOR_API_KEY" \
--set secrets.gatewayMetricsApiKey="$METRICS_API_KEY" \
--set secrets.gatewayEncryptionMasterPassword="$ENCRYPTION_PASSWORD" \
--set secrets.auditHmacSecret="$AUDIT_HMAC" \
--set secrets.mockProviderEnabled=true
For any deployment past initial smoke-testing, move these into a values file (or an externally-managed Secret via secrets.existingSecret) rather than passing on the command line.
Every pod boots without enterpriseLicenseKey — nothing hard-fails on a missing license as of 1.7.0. An unlicensed install runs in the Development posture and serves the complete product except the MCP and A2A planes, which simply do not activate. (There are no separate MCP or A2A pods to fail: since 1.7.0 all three planes run inside the gateway process.) The actuator chain returns 401 on every authenticated endpoint if the two API keys are unset. ENCRYPTED-mode credential persistence fails if the encryption master password is unset. The audit chain refuses to write on a production-class profile if the HMAC secret is unset or carries the default placeholder.
Installing with Provider Keys
helm install dvara charts/dvara/ \
--version 1.7.0 \
--set secrets.providerKeys.openai=sk-... \
--set secrets.providerKeys.anthropic=sk-ant-...
Or use a values file:
# my-values.yaml
secrets:
providerKeys:
openai: sk-...
anthropic: sk-ant-...
gemini: AIza...
helm install dvara charts/dvara/ --version 1.7.0 -f my-values.yaml
Configuration Reference
Gateway Server
| Parameter | Description | Default |
|---|---|---|
llmGatewayServer.enabled | Enable gateway-server | true |
llmGatewayServer.replicaCount | Replicas (ignored when HPA enabled) | 1 |
llmGatewayServer.image.repository | Image repository | ghcr.io/dvarahq/dvara-gateway |
llmGatewayServer.image.tag | Image tag (defaults to chart appVersion) | "" |
llmGatewayServer.region.id | Region identity for multi-region deployments | "" |
llmGatewayServer.region.name | Human-readable region name | "" |
llmGatewayServer.javaOpts | JVM options | "" |
llmGatewayServer.resources.requests.cpu | CPU request | 250m |
llmGatewayServer.resources.requests.memory | Memory request | 512Mi |
llmGatewayServer.resources.limits.cpu | CPU limit | 2 |
llmGatewayServer.resources.limits.memory | Memory limit | 1Gi |
llmGatewayServer.service.type | Service type | ClusterIP |
llmGatewayServer.service.port | Service port | 8080 |
Flightdeck (Console + Portal)
The Helm chart's flightdeck.* parameter family configures the DVARA Flightdeck pod (Console + workspace Portal); the deployed image is ghcr.io/dvarahq/dvara-flightdeck.
| Parameter | Description | Default |
|---|---|---|
flightdeck.enabled | Enable flightdeck | true |
flightdeck.replicaCount | Number of replicas | 1 |
flightdeck.image.repository | Image repository | ghcr.io/dvarahq/dvara-flightdeck |
flightdeck.llmGatewayServerUrl | Override auto-discovered server URL | "" (auto) |
flightdeck.resources.requests.cpu | CPU request | 100m |
flightdeck.resources.requests.memory | Memory request | 256Mi |
flightdeck.service.type | Service type | ClusterIP |
flightdeck.email.transport | Email transport — log, smtp, or resend. Unset renders no email config at all | "" |
flightdeck.email.from | Sender address on every outbound | "" |
flightdeck.email.host | SMTP server hostname. Required when transport=smtp — the pod refuses to start without it | "" |
flightdeck.email.port | SMTP port | "" |
flightdeck.email.username | SMTP username, when the server requires auth | "" |
flightdeck.email.existingSecret | Name of an existing Secret holding the SMTP / Resend credential. Preferred over password | "" |
flightdeck.email.existingSecretKey | Key within existingSecret | email-password |
flightdeck.email.password | Credential inline. Lands in helm get values output — prefer existingSecret | "" |
flightdeck.service.port | Service port | 8090 |
Flightdeck Health Probes:
| Probe | Path | Purpose |
|---|---|---|
| Liveness | /actuator/health/liveness | Basic liveness check |
| Readiness | /actuator/health/readiness | Includes controlPlane check (gateway-server connectivity) |
| Startup | /actuator/health/liveness | Allows JVM warmup before liveness kicks in |
All four probe paths (/actuator/health, /actuator/health/liveness, /actuator/health/readiness, /actuator/info) are anonymous by design so k8s probes work without secrets. Do not set management.endpoint.health.show-details=always — the gateway refuses to start in that mode so anonymous callers cannot read per-indicator JSON like cache-cluster state or database pool internals. Leave the default when-authorized.
Secrets
| Parameter | Description | Default |
|---|---|---|
secrets.create | Create the Secret resource | true |
secrets.existingSecret | Use an existing Secret instead | "" |
secrets.providerKeys.openai | OpenAI API key | "" |
secrets.providerKeys.anthropic | Anthropic API key | "" |
secrets.providerKeys.gemini | Gemini API key | "" |
secrets.providerKeys.awsAccessKeyId | AWS access key (Bedrock) | "" |
secrets.providerKeys.awsSecretAccessKey | AWS secret key (Bedrock) | "" |
secrets.ollamaEnabled | Enable Ollama provider | "" |
secrets.ollamaBaseUrl | Ollama base URL | "" |
secrets.bedrockEnabled | Enable Bedrock provider | "" |
secrets.mockProviderEnabled | Enable mock provider | "" |
secrets.auditHmacSecret | HMAC-SHA256 key signing the tamper-evident audit chain. Required on production-class profiles — boot refuses the default-dev-secret-change-in-production placeholder. Generate with openssl rand -base64 32. | "" |
secrets.gatewayEncryptionMasterPassword | AES-256-GCM master password for ENC: values + ENCRYPTED-mode provider credentials | "" |
secrets.llmGatewayServerApiKey | Operator Bearer for /actuator/gateway-status + every authenticated /actuator/* path EXCEPT prometheus. Generate with openssl rand -base64 32. Required — every authenticated actuator probe 401s without it. | "" |
secrets.gatewayMetricsApiKey | Distinct Bearer for /actuator/prometheus only. Must differ from llmGatewayServerApiKey (principle of least privilege — a leaked scrape token must not unlock the license envelope). Required. | "" |
secrets.enterpriseLicenseKey | DVARA license envelope (DVARA-… prefix, Ed25519-signed). Activates the MCP and A2A planes and confers production rights. Every pod boots without it, in the Development posture, serving everything else. No -ee images are needed — there is one artifact per app. | "" |
secrets.auditHmacSecret → DVARA_AUDIT_HMAC_SECRET signs every row in the audit chain. Since 1.7.0 leaving it unset is safe where a database is configured — the install generates a secret on first boot and persists it, shared across pods. What boot still refuses on a production-class profile is the default-dev-secret-change-in-production placeholder, which is public. Set it explicitly to hold the key material yourself, and always set it — the same value on both halves — where a data-plane pod ships audit to Flightdeck, since the pod signs and Flightdeck verifies without re-signing. Set it via --set secrets.auditHmacSecret=… or through secrets.existingSecret (key audit-hmac-secret).
The dataPlane block
A top-level block, shared by the data-plane workload, that configures the signed config-bundle channel. It renders nothing at all unless dataPlane.controlPlane.baseUrl is set, so an install that is not cutting over is unchanged by its presence.
| value | what it does |
|---|---|
dataPlane.controlPlane.baseUrl | the control plane to fetch bundles from. Setting this is what turns the channel on. |
dataPlane.controlPlane.publicKey | the Ed25519 key that verifies a bundle. Required — the render fails without it rather than deploying a pod that fetches bundles it cannot verify. |
dataPlane.serveFromBundle | whether the pod actually reads from the bundle. Default false — see the two switches. |
dataPlane.tls.secretName | the /internal client certificate. |
dataPlane.dbMigrate | default true. Set false to make Flightdeck the sole migrator — then Flightdeck must migrate before these pods start, so order them behind its readiness. |
dataPlane.tls.secretName sets both SSL properties, which is the part that is easy to get wrong by hand: one carries the client certificate and the other only checks the CN. Setting the second alone gives a pod that starts and then fails every fetch. The chart removes that choice.
Datasource credentials are not part of this block — supply SPRING_DATASOURCE_* through extraEnv.
The bundled cache carries a licensing choice
The chart can deploy a single-node cache for evaluation. Both are disabled by default, and which one you enable is a licensing decision, not a preference:
| value | image | licence |
|---|---|---|
valkey.enabled | valkey/valkey-bundle | BSD-3 |
redis.enabled | redis:8 | tri-licensed RSALv2 / SSPLv1 / AGPLv3 |
Valkey is the documented default. BSD-3 raises no distribution question. Redis Open Source 8+ is AGPLv3 among its options — OSI-approved open source, and copyleft with a network clause, which is a materially different position from the BSD licence Redis carried before March 2024. Enabling redis pulls an AGPL program from its publisher's registry into your cluster; DVARA ships a template naming an image, not the image itself.
If your organisation restricts AGPL software, use Valkey or an external managed instance. This is not legal advice — consult your own counsel.
Neither profile is a production topology. Point production at a managed offering — ElastiCache for Valkey, Memorystore, Azure Cache.
MCP and A2A planes
There are no mcpGatewayServer or a2aGatewayServer values to set. Both planes run inside the
LLM gateway and are served by it whenever the license permits, on the same port as /v1/*. The
separate dvara-mcp-gateway and dvara-a2a-gateway images are no longer published and their
workload templates are gone.
The chart refuses to render if you enable either — a hard failure naming the replacement, rather than silently giving you a smaller cluster than you asked for with the MCP plane quietly not served.
To use them: apply a license and address /mcp/* and /a2a/* on the gateway Service.
Ingress
| Parameter | Description | Default |
|---|---|---|
ingress.enabled | Enable Ingress | false |
ingress.className | Ingress class (nginx, traefik, alb) | "" |
ingress.annotations | Ingress annotations | {} |
ingress.llmGatewayServer.hosts | Server host/path rules | [{host: gateway.example.com}] |
ingress.llmGatewayServer.tls | Server TLS config | [] |
ingress.flightdeck.hosts | UI host/path rules | [{host: admin.example.com}] |
ingress.flightdeck.tls | UI TLS config | [] |
Graceful Shutdown & Rolling Updates
| Parameter | Description | Default |
|---|---|---|
llmGatewayServer.terminationGracePeriodSeconds | Pod termination grace period (must exceed preStop + drain) | 45 |
llmGatewayServer.preStopSleepSeconds | Sleep before SIGTERM (endpoint de-registration propagation) | 5 |
llmGatewayServer.rollingUpdate.maxSurge | Max extra pods during rolling update | 1 |
llmGatewayServer.rollingUpdate.maxUnavailable | Max unavailable pods during rolling update (0 = zero-downtime) | 0 |
llmGatewayServer.topologySpreadConstraints | Topology spread for cross-zone scheduling | [] |
The default configuration ensures zero-downtime rolling updates: maxSurge: 1 creates one new pod before terminating old ones, and maxUnavailable: 0 ensures at least N pods are always ready. The preStopSleepSeconds delay allows Kubernetes endpoint propagation to complete before the application receives SIGTERM and begins its 30-second graceful drain.
Autoscaling (HPA)
| Parameter | Description | Default |
|---|---|---|
llmGatewayServer.autoscaling.enabled | Enable HPA | false |
llmGatewayServer.autoscaling.minReplicas | Minimum replicas | 2 |
llmGatewayServer.autoscaling.maxReplicas | Maximum replicas | 10 |
llmGatewayServer.autoscaling.targetCPUUtilizationPercentage | CPU target | 70 |
llmGatewayServer.autoscaling.targetMemoryUtilizationPercentage | Memory target | 80 |
llmGatewayServer.autoscaling.behavior.scaleUp.stabilizationWindowSeconds | Wait before scaling up | 30 |
llmGatewayServer.autoscaling.behavior.scaleDown.stabilizationWindowSeconds | Wait before scaling down | 300 |
The default HPA behavior scales up quickly (50% per minute after 30s stabilization) but scales down conservatively (25% per 2 minutes after 5-minute stabilization) to prevent flapping.
Pod Disruption Budget
| Parameter | Description | Default |
|---|---|---|
llmGatewayServer.pdb.enabled | Enable PDB | false |
llmGatewayServer.pdb.minAvailable | Min available pods | 1 |
llmGatewayServer.pdb.maxUnavailable | Max unavailable pods | "" |
Prometheus ServiceMonitor
| Parameter | Description | Default |
|---|---|---|
llmGatewayServer.serviceMonitor.enabled | Enable (requires Prometheus Operator) | false |
llmGatewayServer.serviceMonitor.interval | Scrape interval | 30s |
llmGatewayServer.serviceMonitor.path | Metrics path | /actuator/prometheus |
llmGatewayServer.serviceMonitor.additionalLabels | Labels for monitor selection | {} |
When llmGatewayServer.serviceMonitor.enabled=true, configure the ServiceMonitor's bearerTokenFile (or the Helm equivalent) to point at a file containing the DVARA_ACTUATOR_METRICS_API_KEY value. /actuator/prometheus is authenticated — without the token, every scrape returns 401 and the time series goes dark. The metrics secret is intentionally distinct from DVARA_ACTUATOR_API_KEY so a leaked scrape token can't unlock the rich gateway status surface.
Clustering on Kubernetes
DVARA LLM Gateway instances share rate-limit counters and API key lookups across the fleet. On Kubernetes, pods must discover each other via a headless Service — multicast is unavailable in most clusters.
When KUBERNETES_NAMESPACE is set (the downward API auto-injects this), the gateway requires CACHE_SERVICE_NAME to point at a headless Service fronting the gateway pods. Without it, startup fails with:
Kubernetes clustering requires CACHE_SERVICE_NAME when
KUBERNETES_NAMESPACE is set. Without it, pods cannot form a cluster and
rate limit state will not be shared.
The official Helm chart wires both variables automatically when you deploy multiple gateway replicas — you don't need the manual YAML below unless you're bypassing the chart. Outside Kubernetes (local docker compose, bare metal), gateway instances auto-discover each other via multicast and CACHE_SERVICE_NAME is not required.
Headless Service pattern (reference)
apiVersion: v1
kind: Service
metadata:
name: dvara-server-cluster
labels:
app.kubernetes.io/name: dvara-server
spec:
clusterIP: None # headless — each pod gets a DNS A record
publishNotReadyAddresses: true
selector:
app.kubernetes.io/name: dvara-server
ports:
- name: cluster
port: 5701
targetPort: 5701
Deployment environment variables (reference)
apiVersion: apps/v1
kind: Deployment
metadata:
name: dvara-server
spec:
template:
spec:
containers:
- name: gateway-server
env:
- name: KUBERNETES_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: CACHE_SERVICE_NAME
value: dvara-server-cluster
Common Deployment Patterns
Minimal Mode (Testing)
PostgreSQL is required even for minimal deployments — there is no in-memory fallback. For quick testing, point the chart at an external Postgres or deploy a small Postgres StatefulSet alongside the gateway:
helm install dvara charts/dvara/ \
--version 1.7.0 \
--set secrets.mockProviderEnabled=true \
--set llmGatewayServer.env.SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/dvara \
--set llmGatewayServer.env.SPRING_DATASOURCE_USERNAME=dvara \
--set llmGatewayServer.env.SPRING_DATASOURCE_PASSWORD=dvara
Production with OpenAI
# production-values.yaml
llmGatewayServer:
replicaCount: 3
# Match heap to container limits below — leave ~25% of the limit for
# off-heap (Metaspace, native code, JIT, kernel buffers). With memory
# limit = 2Gi, -Xmx1500m is a safe upper bound; reserve more if you
# see Metaspace pressure in JFR.
javaOpts: "-Xms1g -Xmx1500m"
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 2Gi
terminationGracePeriodSeconds: 45
preStopSleepSeconds: 5
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
pdb:
enabled: true
minAvailable: 2
secrets:
providerKeys:
openai: sk-...
ingress:
enabled: true
className: nginx
llmGatewayServer:
hosts:
- host: gateway.mycompany.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: gateway-tls
hosts:
- gateway.mycompany.com
helm install dvara charts/dvara/ --version 1.7.0 -f production-values.yaml
MCP and A2A governance
No extra values are needed. Supply a license and the planes are served by the gateway:
# licensed-values.yaml
secrets:
enterpriseLicenseKey: "DVARA-..." # activates the MCP and A2A planes
llmGatewayServer:
replicaCount: 3
helm install dvara oci://ghcr.io/dvarahq/charts/dvara --version 1.7.0 \
-n dvara --create-namespace -f licensed-values.yaml
/mcp/* and /a2a/* are then served on the gateway Service alongside /v1/*.
Inline Gateway Configuration
Pass a gateway.yaml configuration directly via Helm values:
llmGatewayServer:
gatewayConfig:
routing:
default-strategy: round-robin
rate-limit:
enabled: true
per-key:
requests-per-minute: 100
This creates a ConfigMap mounted into the gateway pod and applied as Spring Boot externalized configuration.
Using External Secrets
If you manage secrets with External Secrets Operator, Sealed Secrets, or a vault:
secrets:
create: false
existingSecret: my-external-secret
The existing Secret must contain the same keys: openai-api-key, anthropic-api-key, gemini-api-key, aws-access-key-id, aws-secret-access-key, ollama-enabled, ollama-base-url, bedrock-enabled, mock-provider-enabled, gateway-encryption-master-password, gateway-server-api-key, gateway-metrics-api-key, audit-hmac-secret, enterprise-license-key (signed).
AWS Bedrock with IRSA
llmGatewayServer:
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/dvara-bedrock
secrets:
bedrockEnabled: "true"
Multi-Region Deployment
Deploy an instance in a specific region:
# us-east-values.yaml
llmGatewayServer:
region:
id: us-east-1
name: US East
secrets:
providerKeys:
openai: sk-...
helm install dvara-us-east charts/dvara/ --version 1.7.0 -f us-east-values.yaml
When region.id is set, the chart adds DVARA_REGION_ID and DVARA_REGION_NAME environment variables (which bind to the region identity configuration) and a dvara.ai/region pod label for topology-aware scheduling.
Prometheus Monitoring
llmGatewayServer:
serviceMonitor:
enabled: true
interval: 15s
additionalLabels:
release: prometheus-stack
The ServiceMonitor scrapes /actuator/prometheus. Requires the Prometheus Operator CRDs to be installed and a bearerTokenFile (or Helm-managed equivalent) pointing at the DVARA_ACTUATOR_METRICS_API_KEY value — see the ServiceMonitor parameter table above.
Security
The chart applies security hardening by default:
- Non-root execution — Pods run as UID 1001 (
runAsNonRoot: true) - Read-only filesystem —
readOnlyRootFilesystem: truewith a/tmpemptyDir for JVM temp files - No privilege escalation —
allowPrivilegeEscalation: false - Capabilities dropped — All Linux capabilities dropped
- No service account token —
automountServiceAccountToken: false(no K8s API access needed) - Secret key refs optional — Pods start even if only some provider keys are configured
Upgrading
# From OCI registry
helm upgrade dvara oci://ghcr.io/dvarahq/charts/dvara --version 1.7.0 -f my-values.yaml
# From local chart
helm upgrade dvara charts/dvara/ --version 1.7.0 -f my-values.yaml
Pods automatically restart when secrets or ConfigMap content changes (via checksum annotations on the pod template).
Running Helm Tests
helm test dvara
This runs test pods that verify gateway-server, flightdeck, and (if enabled) mcp-proxy-server services are reachable.
Uninstalling
helm uninstall dvara
Troubleshooting
Pods stuck in CrashLoopBackOff
Check logs for JVM startup errors:
kubectl logs deployment/dvara-server
Common causes:
- Insufficient memory — increase
resources.limits.memory - Missing secret keys — verify the Secret exists:
kubectl get secret dvara -o yaml
Startup probe fails
The startup probe allows 60 seconds (5s initial + 12 retries x 5s) for JVM warmup. If your image is large or the node is slow, increase the startup probe:
llmGatewayServer:
startupProbe:
failureThreshold: 20
Services not reachable
# Check pod status
kubectl get pods -l app.kubernetes.io/component=gateway-server
# Check service endpoints
kubectl get endpoints dvara-server
# Port-forward to test directly
kubectl port-forward svc/dvara-server 8080:8080
curl http://localhost:8080/actuator/health
Providers not registering
Provider keys must be non-empty strings. Check the Secret:
kubectl get secret dvara -o jsonpath='{.data.openai-api-key}' | base64 -d
Empty string = provider disabled (this is expected for unused providers).