Kubernetes & Helm
Deploy and scale Telmoni on Kubernetes using the official production Helm chart.
Telmoni provides a Helm chart located in deploy/charts/telmoni. It packages the core backend server, the Next.js web console, a migration hook Job, a partition-rotation CronJob, network policies, and an optional in-cluster Ollama vector embeddings deployment.
Architecture on Kubernetes
flowchart TD
Ingress["GKE Gateway (the chart's ingress, off by default) or your own route"] --> WebSvc["Service: web (Port 3000)"]
Ingress -->|"/otlp, /v1, /cli, /webhooks/v1/slack: by host or by path"| ApiSvc["Service: server-api (Port 8082)"]
WebSvc --> WebPods["Deployment: web (Next.js BFF)"]
WebPods --> ServerSvc["Service: server (Port 8082)"]
ServerSvc --> ServerPods["Deployment: server (Rust API)"]
ApiSvc --> ServerPods
WebPods --> Redis[("Redis 7 (outside the chart)")]
ServerPods --> Redis
ServerPods --> Postgres[("PostgreSQL 17 + pgvector")]
ServerPods --> ClickHouse[("ClickHouse (outside the chart)")]
ServerPods --> EmbeddingsSvc["Service: embeddings (Port 11434)"]
EmbeddingsSvc --> EmbeddingsPod["Deployment: embeddings (Ollama)"]
HookJob["Pre-install / pre-upgrade hook Job: telmoni-migrate"] -.->|Runs DDL & Grants| Postgres
HookJob -.->|Database & tables| ClickHouse
RotateCron["CronJob: rotate (telmoni rotate)"] -.->|Scheduled partition maintenance| Postgres
RotateCron -.->|Drops days of spans past retention| ClickHousePrerequisites
Before deploying the Helm chart, ensure you have:
- A Kubernetes cluster on Google Cloud, GKE with Workload Identity or your own on Compute Engine nodes: in a pod,
TELEMETRY_KEKandCONNECTOR_KEKmust be Google Cloud KMS keys, reached with a token from Google's metadata server (the last item below), which a cluster elsewhere does not have - Helm 3 (the chart's
apiVersionisv2) - Nodes that run
linux/amd64: the published images are built for it alone, taggedlatestand with the full SHA of their commit, whichglobal.imageTagpins - A PostgreSQL 17 database instance with the
vectorextension, pgvector 0.8 or later, created by a superuser before the first migration - The five login roles
auth,notifications,agent,telemetryandmigrator, created by you, each with its own password, then hardened withrole_hardening.sqlas the production guide describes; the connection strings below log in as them - A ClickHouse server outside the chart, one node, reached over its HTTP interface (8123 for
httpand 8443 forhttps, by default), with two users of its own:migrator, which the migration and rotation run as, grantedCREATE DATABASE, CREATE TABLE, CREATE VIEW, CREATE ROW POLICY, SELECT, INSERT, ALTER DELETE ON telemetry.*, the last for dropping partitions past retention and rewriting away the rows a deleted project left. It creates thetelemetrydatabase, its tables, a row policy that holds every user but itself to the projects each read names, and one that lets it read every row, for the nightly drop; so the server's user must not be the migrator's. Name the rights one by one rather thanALL ON telemetry.*: ClickHouse grantsALLon the bare nametelemetrytoo, which is the server's user's, and with it the rights to alter or drop that user and to act as it.telemetry, which the server connects as, grantedSELECT, INSERT, ALTER DELETE ON telemetry.spansandSELECT, ALTER DELETE ON telemetry.spans_hourly, and nothing else in the database.ALTER DELETEis what a lightweight delete needs: the server marks a deleted project's rows with one in each table, naming that one project. A grant on all of the database would take in the view that fills the hourly totals, which reads as the migrator, past the row policy, and so answers every project's totals.
- ClickHouse's configuration:
crates/telemetry/clickhouse/low-memory.xml, the one to give a one-node ClickHouse and the one that keeps a customer's values out of ClickHouse's own log, its logger stopping at warnings, writing no query's error and masking every quoted literal anything else writes. Use it whole.crates/telemetry/clickhouse/dev-users.xmlshows the two users and their grants as ausers.dfile, but its passwords are the development ones in plain text and it admits every network: write yours withpassword_sha256_hexandnetworksnarrowed to your pods' range. The server must keep one default:custom_settings_prefixesallowingSQL_, or every read is refused - A way to ClickHouse that a pod accepts:
httpswith a certificate from a public CA, or plainhttpto loopback or a Service's name inside the cluster, with its namespace (clickhouse.<namespace>.svc,….svc.cluster.local): a bare name no Service in the namespace answers falls through the node's DNS search path to whatever outside the cluster does. In a pod, the server and the migrator refusehttpto any other host, a VM on the same network included, and trust no private CA for ClickHouse. A ClickHouse inside the cluster needs twoNetworkPolicys of your own. One lets theserverandmigratorpods reach it on 8123, by pod and namespace selector: the chart's egress rule names addresses, which Kubernetes means for destinations outside the cluster, and which some network plugins, Cilium's among them, do not match against pods. The other admits them to ClickHouse's pod when it runs in the chart's own namespace, which the chart closes to all traffic - A Redis 7 instance outside the chart, reached at a single endpoint (the console does not support Redis Cluster mode)
- A route from outside to the
webService on port 3000, and one for the machine lanes — ingest at/otlp/v1/traces, where the SDKs send; the read API under/v1; the CLI's sign-in under/cli/v1; Slack's signed events at/webhooks/v1/slack— to theserver-apiService on port 8082: on a host of their own (api.telmoni.example.com, then the SDKs' endpoint) or under their paths on the console's host (then the endpoint), and only those paths, since the rest of the server is the console's to call. The chart's owningressis a GKE Gateway (a Gateway APIGatewayon a named static address with an HTTPS listener and an HTTP-to-HTTPS redirect, plus GKE'sHealthCheckPolicyon/api/healthand aGCPBackendPolicywith a 3,600-second timeout for the console's event streams, and anHTTPRoutefor the lanes with aHealthCheckPolicyon/livez) and is off by default; on another cluster, bring your own Ingress or Gateway for both - For the key each project's user ids are hashed under (
TELEMETRY_KEK), and for connectors (Slack and webhooks alike,CONNECTOR_KEK): a Google Cloud KMS key for each, which the server pods may encrypt and decrypt with. They reach it with the token the GKE metadata server gives their Kubernetes service account,server, which the chart creates without a Workload Identity annotation, so after installing the chart either grant the key's encrypt and decrypt roles to that account's principal (principal://iam.googleapis.com/projects/<project number>/locations/global/workloadIdentityPools/<project>.svc.id.goog/subject/ns/telmoni/sa/server) or bind a Google service account that holds them (kubectl annotate serviceaccount server -n telmoni iam.gke.io/gcp-service-account=<account>, then restart the server pods). The server does not try the keys at boot: a missing binding first shows as a502/errors/connectors/key-unavailablewhen a connector is set up, and forTELEMETRY_KEKas a503/errors/telemetry/store-unavailableon a project's first export, which exporters retry. A mounted key file is not read, and alocal:CONNECTOR_KEKorTELEMETRY_KEKis refused in a pod
Deployment steps
Prepare namespace and Kubernetes secrets
Create a dedicated namespace:
kubectl create namespace telmoniGenerate random 32-byte tokens for the service communication secret and session cookie seal:
SERVICE_SECRET=$(openssl rand -hex 32)
AUTH_SECRET=$(openssl rand -hex 32)DATABASE_CA_CERT (the PEM of the database's CA) belongs in both Secrets that reach the database: server-secrets, and migrator-secrets, which holds MIGRATOR_DATABASE_URL, MIGRATOR_CLICKHOUSE_URL and DATABASE_CA_CERT (the console's web-secrets holds no database settings). In a Kubernetes pod the server and the migrator refuse to start without it.
Create the required Kubernetes secret objects. Each database URL carries the password you gave its role in the production guide, and each ClickHouse URL the password of its ClickHouse user, with any character other than a letter, a digit, -, ., _ or ~ percent-encoded (@ as %40). The ClickHouse URLs below reach a server outside the cluster over https, its certificate a public CA's; one inside it is http://telemetry:<password>@clickhouse.<namespace>.svc:8123 instead, and the same for migrator. The AGENT_MODEL_API_KEY line is for a hosted model only; leave it out otherwise.
# 1. Server secrets
kubectl create secret generic server-secrets \
--namespace telmoni \
--from-literal=AUTH_DATABASE_URL="postgres://auth:<auth password>@postgres.internal:5432/telmoni" \
--from-literal=NOTIFICATIONS_DATABASE_URL="postgres://notifications:<notifications password>@postgres.internal:5432/telmoni" \
--from-literal=AGENT_DATABASE_URL="postgres://agent:<agent password>@postgres.internal:5432/telmoni" \
--from-literal=TELEMETRY_DATABASE_URL="postgres://telemetry:<telemetry password>@postgres.internal:5432/telmoni" \
--from-literal=TELEMETRY_CLICKHOUSE_URL="https://telemetry:<ClickHouse telemetry password>@clickhouse.example.com:8443" \
--from-literal=REDIS_URL="redis://redis.internal:6379" \
--from-literal=SERVICE_SECRET="$SERVICE_SECRET" \
--from-literal=ADMIN_EMAIL="admin@example.com" \
--from-literal=ADMIN_PASSWORD="<admin password>" \
--from-literal=AGENT_MODEL_API_KEY="sk-..." \
--from-file=DATABASE_CA_CERT=/path/to/database-ca.pem
# 2. Web console secrets
kubectl create secret generic web-secrets \
--namespace telmoni \
--from-literal=SERVICE_SECRET="$SERVICE_SECRET" \
--from-literal=AUTH_SECRET="$AUTH_SECRET" \
--from-literal=REDIS_URL="redis://redis.internal:6379"
# 3. Migrator secrets
kubectl create secret generic migrator-secrets \
--namespace telmoni \
--from-literal=MIGRATOR_DATABASE_URL="postgres://migrator:<migrator password>@postgres.internal:5432/telmoni" \
--from-literal=MIGRATOR_CLICKHOUSE_URL="https://migrator:<ClickHouse migrator password>@clickhouse.example.com:8443" \
--from-file=DATABASE_CA_CERT=/path/to/database-ca.pemEach Secret is handed to its pods whole, as environment. server-secrets is therefore also where the server's other secrets go when you use them: SMTP_URL, the identity provider's AUTH_CUSTOM_CLIENT_ID, AUTH_CUSTOM_CLIENT_SECRET and AUTH_CUSTOM_ISSUER, the SLACK_* credentials, EMBEDDINGS_API_KEY, RERANK_API_KEY and TELMONI_API_KEY. REDIS_CA_CERT, for a rediss:// URL, belongs in web-secrets: the console reads it, and the server does not, trusting the certificate authorities its image carries — so a Redis whose certificate a private authority signed is one the server cannot reach: its limits fall back to each replica's own, and ingest claims no export, so a copy an exporter sends again is written again, all of which it logs. Everything else the two read comes from the chart: the values under config through the telmoni-tier ConfigMap (APP_URL, AUTH_URL, LOG_LEVEL, the sign-in switches and the provider's other AUTH_CUSTOM_* settings, AUTH_REDIRECT_URI from config.redirectUri, which defaults to http://localhost:3000/auth/callback and is set to https://<your host>/auth/callback with an identity provider, AUTH_PROVIDER_ORIGINS, COMPANY_NAME, SUPPORT_EMAIL, LEGAL_URL, MAIL_FROM, CONNECTOR_KEK, TELEMETRY_KEK and the agent settings, with TRUSTED_PROXY_HOPS from web.trustedProxyHops and LIVE_REFRESH_HOLD_SECS from web.liveRefreshHoldSeconds), and PORT and SERVER_URL from the Deployments themselves. Pods read them when they start, and a helm upgrade that changes only values under config, or a changed Secret, restarts no pod, so follow it with kubectl rollout restart deployment -n telmoni.
Configure Helm values
Create a custom values-prod.yaml file.
Notice that TELEMETRY_KEK and CONNECTOR_KEK are not secrets, and a local: key is refused in a pod. They come from the chart values config.telemetryKek and config.connectorKek, whose defaults (local:dev_key) are refused there too, so the server will not boot until you set them: a Cloud KMS key name like projects/<p>/locations/<l>/keyRings/<r>/cryptoKeys/<k> for each — one key may serve both — or, for connectorKek alone, "" to run without connectors. The server also reads REDIS_URL from server-secrets, the same Redis as the console's, for ingest's claims and rate limits:
config:
logLevel: "info"
appUrl: "https://telmoni.example.com"
authUrl: "https://telmoni.example.com"
disableSignup: true
verifyEmail: false
connectorKek: "projects/my-project/locations/global/keyRings/my-ring/cryptoKeys/connector-kek"
telemetryKek: "projects/my-project/locations/global/keyRings/my-ring/cryptoKeys/telemetry-kek"
# Optional AI Agent configuration. Anthropic's, OpenAI's or Google's API
# needs AGENT_MODEL_API_KEY in server-secrets, or the server refuses to start.
agent:
modelProvider: "anthropic" # or "openai"
model: "claude-sonnet-5"
# If embeddingsUrl is left empty and embeddings.enabled is true,
# the chart automatically routes to the in-cluster Ollama service.
embeddingsUrl: ""
embeddingsModel: "nomic-embed-text"
# In-cluster Ollama deployment for local embeddings
embeddings:
enabled: true
model: "nomic-embed-text"
resources:
requests:
cpu: "500m"
memory: "2Gi"
limits:
memory: "4Gi"
# Core backend server
server:
enabled: true
replicas: 2
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
memory: "1Gi"
# Web console (Next.js). Its per-address rate limits read X-Forwarded-For
# trustedProxyHops entries back from the end: the GKE Gateway appends the
# client's address and then its own, so 2 is the default; change it only
# behind another appending hop.
web:
enabled: true
replicas: 2
trustedProxyHops: 2
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
memory: "512Mi"
# High availability configurations
highAvailability:
enabled: true
minReplicas: 2
maxReplicas: 3
targetCpuUtilization: 70
# Database migration and partition maintenance
migrator:
enabled: true
hook:
enabled: true # Executes as a pre-install / pre-upgrade Helm hook
rotateSchedule: "0 2 * * *" # Daily partition rotationThe sign-in settings are values under config as well, each the AUTH_* variable of the same name in the configuration reference, with its default: disableSignup (true, so that only an invited address can sign up), disableUsernamePassword, domainsWithSsoEnforcement and redirectUri, and the provider's customName, customScope, customChecks, customIdTokenSignedResponseAlg, customFetchUserinfo and customAllowAccountLinking, whose client id, secret and issuer are the server-secrets keys above. Where every new person starts is defaultOrgId, defaultOrgRole, defaultProjectId and defaultProjectRole, the TELMONI_DEFAULT_* variables of the default organization, all empty unless set.
Enabling highAvailability.enabled creates a HorizontalPodAutoscaler and a PodDisruptionBudget for both web and server (deploy/charts/telmoni/templates/high-availability.yaml).
Install the Helm chart
From your repository root, run helm install:
helm install telmoni ./deploy/charts/telmoni \
--namespace telmoni \
--values values-prod.yamlVerify the installation
Check the pods:
kubectl get pods -n telmoniThe server and web Deployments run between the autoscaler's minReplicas and maxReplicas (two to three above; with highAvailability.enabled off, the replicas you set) and embeddings one pod, each Running and 1/1 once its probes pass: the server's readiness probe reads every module's tables (/health, with /livez for liveness), the console's is /api/health, and the embeddings pod is ready only once its model has been pulled. The migration Job, telmoni-migrate, is a Helm hook deleted as soon as it succeeds, so its pod is listed only while it runs or after it failed, and a failed migration fails helm install itself.
Upgrading
The chart runs the images tagged global.imageTag, latest unless you pin it, with imagePullPolicy: IfNotPresent, so a node that already holds latest keeps running it. Pin global.imageTag to the full SHA of a commit, and change it to upgrade: helm upgrade runs the migration Job first, as a pre-upgrade hook, and a failed migration fails the upgrade.
helm upgrade telmoni ./deploy/charts/telmoni \
--namespace telmoni \
--values values-prod.yaml \
--set global.imageTag=<commit SHA>Kubernetes security & isolation
The Telmoni Helm chart sets these defaults:
- Non-root enforcement: Every container runs as UID
65532withrunAsNonRoot: true,allowPrivilegeEscalation: false, theRuntimeDefaultseccomp profile anddrop: [ALL], and no pod mounts a service-account token or receives service-link environment variables. The server, the migrator jobs and the embeddings pod mount a read-only root filesystem (the embeddings pod writes its model to anemptyDir), while the console's stays writable for Next's render cache. - NetworkPolicies:
default-deny-allrefuses every pod's ingress and egress, and the rest open only what is used:allow-kube-dns: every pod may reach cluster DNS on port 53.allow-web-ingress: the console accepts port3000from anywhere, since the load balancer reaches its pods directly.allow-web-egress: the console reaches theserverpods on8082and any address on6378or6379, for Redis, and nothing else.allow-server-ingress: the server accepts8082from anywhere, as the console does, since the load balancer reaches its pods directly for the machine lanes; each lane has a gate of its own, the service secret or a project API key.allow-server-and-jobs-egress: the server and the migrator jobs may reach any address, for the database, ClickHouse, Redis, the mail and identity vendors, the KMS key and customers' webhooks; the server's own URL guard is what keeps a webhook off private addresses. A ClickHouse inside the cluster needs policies of your own (see Prerequisites).allow-embeddings-ingress,allow-embeddings-egressandallow-server-to-embeddings, withembeddings.enabled: the embeddings pod accepts11434from theserverpods alone and may reach any host on port 443 for the model pull at each start, and the server may reach it on11434.
- Pre-upgrade Migration Safety: Migrations run as a Kubernetes
Jobannotated"helm.sh/hook": pre-install,pre-upgradeat weight-5, withbackoffLimit: 0andactiveDeadlineSeconds: 300; thetelmoni-tierConfigMap and themigratorServiceAccount it needs are hooks too, at weight-10, and are kept. If a migration fails, the release fails before any Deployment is updated, though thetelmoni-tierConfigMap already holds the new values, and a pod that restarts afterwards reads them. - Partition Management: The chart runs partition rotation itself, as a CronJob named
rotate(migrator.rotateSchedule, default"0 2 * * *", inEtc/UTC, never two at once), runningtelmoni rotateas the migrator role to ensure the current month and the next three months of audit partitions exist ahead of time, and, as the migrator's ClickHouse user, to drop each day of spans once every span in it arrived more than 360 days ago, and each month of hourly totals once it is more than 13 months behind.
Reaching the console and the machine lanes
ingress.enabled: true renders the chart's GKE Gateway: ingress.gatewayClassName (default gke-l7-global-external-managed), ingress.staticAddressName (default telmoni-web, a global static address you have reserved) and ingress.annotations (default networking.gke.io/certmap: telmoni-web, the certificate map holding the console's certificate). Port 80 answers a 301 to HTTPS, and the Gateway's health check reads /api/health. That Gateway appends two X-Forwarded-For entries, which is what web.trustedProxyHops: 2 counts.
The machine lanes ride the same Gateway, to the server-api Service. ingress.apiHost names a host of their own (api.telmoni.example.com), whose certificate the map must hold too and which is then what an SDK's endpoint names; left empty, the lanes are routed by path on the console's host, which is then the endpoint, and the paths (/otlp, /v1, /cli, /webhooks) are reserved slugs no organization can take. On either host only the lanes' paths reach the server: everything else on it is the console's to call, behind the service secret. The lanes' health check is /livez, which touches no database, so a database blip does not also refuse an exporter's retries; a backend policy for them is yours to add, as the console's GCPBackendPolicy is the chart's.