The Helm chart in this codebase (deploy/helm/klara-client) deploys everything into one Kubernetes namespace: the backend, admin console (backend + frontend), the Word add-in's frontend, the tenant web app, and self-hosted Postgres, Redis, and Qdrant. Each of the three public-facing pieces gets its own hostname under your domain rather than being routed by path — that's the one structural difference from the Docker Compose path.
| Hostname | Serves |
|---|---|
klara.yourcompany.com | Word add-in taskpane |
klara-app.yourcompany.com | Tenant web app |
klara-admin.yourcompany.com | Admin console |
The chart is written to host more than one deployed instance side by side on the same cluster (its own onboarding flow calls each one a "client") — useful later if you ever want a separate staging environment, but for a first deployment you're just standing up one instance for your own organization.
Source: deploy/helm/klara-client/, deploy/helm/README.md
Before you start
- An Azure subscription with rights to create a resource group, an AKS cluster, and a container registry
- A domain name you control, able to add DNS records for it
- The Klara source code, delivered to you as a
gitrepository or archive az(Azure CLI),docker, andkubectlinstalled locally- An Azure Storage account, and either an Azure OpenAI resource or AWS Bedrock access — same requirements as the Docker Compose path
You do not need helm installed — every Helm command below runs through Docker instead.
Provision the AKS cluster and a registry
Nothing in this repo scripts cluster or registry creation — it starts from the assumption a cluster already exists. What follows is ordinary AKS setup; the node sizing is the one part that is on record elsewhere in this repo, from real capacity testing.
# Registry name must be globally unique — pick your own
az acr create --resource-group <your-resource-group> --name <yourregistry> --sku Standard
az group create --name <your-resource-group> --location <azure-region>
az aks create \
--resource-group <your-resource-group> \
--name <your-cluster-name> \
--nodepool-name nodepool1 \
--node-vm-size Standard_D2s_v3 \
--node-count 2 \
--enable-cluster-autoscaler \
--min-count 2 \
--max-count 4 \
--generate-ssh-keys \
--attach-acr <yourregistry>
The VM size comes from a real, documented incident: 2 nodes at this size turned out to be too few once a second workload was added to a test cluster (0/2 nodes are available: Insufficient cpu). For a single instance you may need less headroom than that multi-tenant test — max-count 4 above is a reasonable starting ceiling, adjustable any time with az aks nodepool update --update-cluster-autoscaler.
Sizing source: deploy/helm/README.md (§ Node capacity)
klaraacr.azurecr.io as its default image.registry, which is Kanerika's own private registry — leaving that unchanged means Kubernetes will try to pull images you have no access to, and every pod will sit stuck in ImagePullBackOff.Connect to the cluster
az login
az account set --subscription <your-subscription-id>
az aks get-credentials --resource-group <your-resource-group> --name <your-cluster-name> --overwrite-existing
kubectl get namespaces
# empty on a fresh cluster — that's expected
az acr login --name <yourregistry>
Pattern source: Documentation/deployment.md
Cluster-wide ingress & TLS
One-time setup for the whole cluster. Installs the shared ingress controller and the certificate issuer your deployment's TLS cert comes from.
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.service.type=LoadBalancer \
--set controller.service.externalTrafficPolicy=Local \
--wait --timeout 5m
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set crds.enabled=true \
--wait --timeout 5m
externalTrafficPolicy: Local is not optional. Azure's load balancer health-checks the plain HTTP port with a bare GET / and no Host header — nginx correctly 404s that, and Azure treats anything but 200 as unhealthy, silently dropping all external traffic. This setting gives the controller a dedicated /healthz port Azure probes correctly instead. It's already fixed in this chart's documented setup — just don't leave it out.deploy/helm/cluster-issuer.yaml is checked into this repo with Kanerika's own contact email registered for Let's Encrypt notices. Open the file, change the email: field to your own address, then apply it:
kubectl apply -f deploy/helm/cluster-issuer.yaml
Get the ingress IP once this is up — every hostname in Part D points here:
kubectl get service -n ingress-nginx ingress-nginx-controller
Source: deploy/helm/README.md, deploy/helm/cluster-issuer.yaml
Point DNS at the cluster
Add three DNS A records — from the table in the overview above — all pointing at the ingress IP from Part C. If your DNS provider is Cloudflare, keep the records DNS-only, not proxied: cert-manager's HTTP-01 challenge needs to reach the cluster directly, and Cloudflare's proxy blocks that.
Pattern source: deploy/helm/README.md
Get the code ready
git clone <the repository URL you were given> klara
cd klara
Build and push your images
One script builds and tags every image with a real, traceable version instead of a drifting latest, and records what it pushed in k8s/versions.yaml. Point it at your own registry with REGISTRY.
export REGISTRY="<yourregistry>.azurecr.io"
export VERSION="$(date +%Y.%m.%d)-$(git rev-parse --short HEAD)"
# The 4 shared images: backend, admin-backend, admin-frontend, webapp
./k8s/build-and-push.sh
# Plus your own frontend image — FRONTEND_URL is baked in at build time
./k8s/build-and-push.sh --client <your-name> --hostname klara.yourcompany.com
Note the version string it prints — you'll set it as image.tag in Part G.
Source: k8s/build-and-push.sh
Deploy your instance
Create your values file
cp deploy/helm/values-clientname.example.yaml deploy/helm/values-<your-name>.yaml
Open it and fill in:
clientName— a short name for your organization, e.g.acmehostname—klara.yourcompany.comwebapp.hostname—klara-app.yourcompany.comadmin.hostname—klara-admin.yourcompany.comimage.registry—<yourregistry>.azurecr.io— required, the chart's own default points at Kanerika's registry, not yoursimage.tag— the version string from Part F- Every
REPLACE_MEundersecrets:— generate each fresh withopenssl rand -hex 32(adminSecretsMasterKeywantsopenssl rand -base64 32)
deploy/helm/values-*.yaml is gitignored on purpose — it holds real secrets. Only *.example.yaml is tracked.Install
docker run --rm \
-v ~/.kube:/root/.kube \
-v "$(pwd)/deploy/helm/klara-client:/chart" \
-v "$(pwd)/deploy/helm/values-<your-name>.yaml:/values.yaml" \
--network host \
alpine/helm:3.14.4 install <your-name> /chart \
-n client-<your-name> --create-namespace -f /values.yaml \
--atomic --timeout 5m0s
--atomic means a failed install rolls itself back automatically — nothing is left half-broken.
Source: Documentation/deployment.md, deploy/helm/values-clientname.example.yaml
Verify it's running
kubectl get pods -n client-<your-name>
# every pod should say Running
kubectl get certificate -n client-<your-name>
# READY should flip to True within 1-2 min once DNS resolves
curl -sk -o /dev/null -w "%{http_code}\n" https://klara.yourcompany.com/taskpane.html
curl -sk -o /dev/null -w "%{http_code}\n" https://klara-app.yourcompany.com/
curl -sk -o /dev/null -w "%{http_code}\n" https://klara-admin.yourcompany.com/
# each should print 200
curl -sk https://klara.yourcompany.com/health/ready
Source: Documentation/deployment.md
First login — and locking down the default account
Every new instance comes with one built-in login, already active:
| Tenant | system |
|---|---|
superuser@klara.test | |
| Password | ChangeMe123! |
Log in at https://klara-admin.yourcompany.com/super-login and change that password immediately — it's public knowledge, sitting in this same source code, so leaving it as-is is a live risk, not a formality. From there, use the admin console's own on-screen flow to create your organization's real tenant and admin account.
klara-demo tenant) is off by default here (BOOTSTRAP_ENABLED=false) — so you won't have to clean up a demo tenant afterward the way the Docker Compose default does. The system superuser login above still exists either way; that's how you get in the first time.Source: Documentation/deployment.md, deploy/helm/README.md
Get the Word add-in to your users
The manifest is already pointing at your live domain (FRONTEND_URL from Part F got baked in at build time). Distribute it one of these ways:
- Microsoft 365 Admin Center (recommended) — Settings → Integrated Apps → Upload custom apps → upload the production
manifest.xmland assign it to users or groups. - SharePoint App Catalog — upload
manifest.xmlthere; users add it via Insert → Add-ins → My Organization. - Manual sideload — fine for testing a handful of machines, not for a real rollout.
The manifest is served directly at https://klara.yourcompany.com/manifest.xml.
Known gaps — read before you rely on this
- Cluster and registry creation aren't scripted anywhere. Part A above is standard Azure practice assembled for this guide, not a documented project process.
- No CI/CD. Building and deploying is a manual, by-hand process — nothing here automates rebuilding and redeploying on a code change.
- Backup restore has never been verified. The chart's optional backup CronJob (
backup.enabledin your values file) dumps Postgres to Blob Storage on a schedule; the restore path itself is untested. - A single-instance root landing page isn't included here. This repo also has a separate cluster-wide "portal" page (
deploy/helm/klara-portal) that helps route people to the right subdomain when a cluster hosts many separate instances — not something a single deployment needs, so it's left out of this guide. Ask if you'd like it added later.
Full chart source
Every file this guide touches — the chart the Helm install in Part G actually reads, plus the build script from Part F — exactly as it stands in this repo. Click any filename to expand it. A couple of lines are changed from the repo original: the example domain is written as yourcompany.com instead of Kanerika's own, the real contact email in the TLS issuer is replaced with a placeholder, and the version ledger is shown empty rather than someone else's build history. Everything else — including the shipped registry default you have to override — is verbatim, so what you copy from here is what actually runs.
Chart metadata & shared defaults
deploy/helm/klara-client/Chart.yaml
apiVersion: v2
name: klara-client
description: >
One full, isolated Klara stack for a single client — backend, admin-console,
frontend, and self-hosted Postgres/Redis/Qdrant. Deployed once per client
into its own Kubernetes namespace; the Ingress hostname is the only thing
that routes a given client's traffic to their own copy of everything.
type: application
version: 0.1.0
appVersion: "1.0.0"deploy/helm/klara-client/values.yaml override image.registry — see Part G
# Defaults shared by every client. Per-client deploys override clientName,
# hostname, and every value under `secrets:` with a real values-<client>.yaml
# file — see deploy/helm/README.md for the exact `helm install` invocation.
clientName: "REPLACE_ME" # e.g. "acme" — used in resource names and the Client row's `code`
hostname: "REPLACE_ME.yourcompany.com"
webapp:
# The standalone React app (KlaraApp/web-app) — shares the backend
# image's build but needs its own hostname since it's served from a
# completely separate service, not routed through frontend's nginx.
enabled: true
hostname: "REPLACE_ME-app.yourcompany.com"
admin:
# Admin console's own public hostname. Not optional/toggleable like webapp
# — admin-backend.yaml uses this for APP_FRONTEND_BASE_URL (drives its own
# CORS allowed-origin AND the unified sign-in's origin-matching check), so
# it must be the admin console's real origin, not the Word add-in's.
hostname: "REPLACE_ME-admin.yourcompany.com"
image:
registry: klaraacr.azurecr.io
tag: "latest" # pin to a real tag per release once CI/CD exists
# Values that MUST be unique per client. Placeholders here are intentionally
# invalid-looking so a values-<client>.yaml that forgets to override one of
# these fails loudly instead of quietly reusing another client's secret.
secrets:
postgresPassword: "REPLACE_ME"
adminPostgresPassword: "REPLACE_ME"
jwtSecretKey: "REPLACE_ME"
addinBridgeKey: "REPLACE_ME"
adminJwtSecret: "REPLACE_ME"
adminSecretsMasterKey: "REPLACE_ME"
azureAdTenantId: ""
azureAdClientId: ""
azureAdClientSecret: ""
awsAccessKeyId: ""
awsSecretAccessKey: ""
awsSessionToken: ""
awsRegion: "us-east-1"
anthropicModel: "global.anthropic.claude-sonnet-4-6"
azureStorageConnectionString: ""
# Optional — vector-search embeddings and vision-based layout QC both call
# Azure OpenAI directly, independent of anthropicModel/AWS above. Leave
# blank to run those two features in mock mode (logged, not an error).
azureOpenaiEndpoint: ""
azureOpenaiApiKey: ""
azureOpenaiDeployment: "gpt-4"
azureOpenaiVisionDeployment: "gpt-4o"
azureOpenaiEmbeddingDeployment: "text-embedding-3-small"
azureOpenaiApiVersion: "2024-02-15-preview"
resources:
backend:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
adminBackend:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
frontend:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "256Mi" }
adminFrontend:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "250m", memory: "128Mi" }
postgres:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
adminPostgres:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
redis:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "512Mi" }
qdrant:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
webapp:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "256Mi" }
storage:
postgresSize: "10Gi"
adminPostgresSize: "5Gi"
qdrantSize: "10Gi"
storageClassName: "default"
backup:
enabled: true
schedule: "0 3 * * *" # daily at 03:00
# Where pg_dump output gets uploaded — an Azure Blob container, distinct
# from Klara's own document-storage container.
azureStorageConnectionString: ""
containerName: "db-backups"Your per-deployment config — copy this one, don't edit it in place
deploy/helm/values-clientname.example.yaml
# Copy this file to values-<client>.yaml (gitignored — never commit a real
# one, it will contain real secrets) and fill in every REPLACE_ME.
#
# cp values-clientname.example.yaml values-acme.yaml
# # edit values-acme.yaml, then:
# helm install acme ./klara-client --namespace client-acme --create-namespace -f values-acme.yaml
clientName: "acme"
hostname: "acme.yourcompany.com"
webapp:
enabled: true
hostname: "acme-app.yourcompany.com"
admin:
hostname: "acme-admin.yourcompany.com"
image:
registry: klaraacr.azurecr.io
tag: "latest"
secrets:
# Generate each of these fresh — never reuse a value from anywhere else.
# E.g.: openssl rand -hex 32
postgresPassword: "REPLACE_ME"
adminPostgresPassword: "REPLACE_ME"
jwtSecretKey: "REPLACE_ME"
addinBridgeKey: "REPLACE_ME"
adminJwtSecret: "REPLACE_ME"
adminSecretsMasterKey: "REPLACE_ME" # openssl rand -base64 32
# Leave blank to keep SSO disabled (LOCAL_ONLY) initially.
azureAdTenantId: ""
azureAdClientId: ""
azureAdClientSecret: ""
awsAccessKeyId: ""
awsSecretAccessKey: ""
awsSessionToken: ""
awsRegion: "us-east-1"
anthropicModel: "global.anthropic.claude-sonnet-4-6"
azureStorageConnectionString: ""
# Optional — leave blank to run vector-search embeddings and vision-based
# layout QC in mock mode.
azureOpenaiEndpoint: ""
azureOpenaiApiKey: ""
azureOpenaiDeployment: "gpt-4"
azureOpenaiVisionDeployment: "gpt-4o"
azureOpenaiEmbeddingDeployment: "text-embedding-3-small"
azureOpenaiApiVersion: "2024-02-15-preview"
backup:
enabled: true
azureStorageConnectionString: "" # separate from the doc-storage one above
containerName: "db-backups"Cluster-wide — apply once, before any client install
deploy/helm/cluster-issuer.yaml edit the email first — see Part C
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: you@yourcompany.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
ingressClassName: nginxApplication workloads
templates/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
name: {{ .Values.clientName }}-secrets
labels:
app.kubernetes.io/part-of: klara-{{ .Values.clientName }}
type: Opaque
stringData:
POSTGRES_PASSWORD: {{ .Values.secrets.postgresPassword | quote }}
ADMIN_POSTGRES_PASSWORD: {{ .Values.secrets.adminPostgresPassword | quote }}
JWT_SECRET_KEY: {{ .Values.secrets.jwtSecretKey | quote }}
ADDIN_BRIDGE_KEY: {{ .Values.secrets.addinBridgeKey | quote }}
ADMIN_JWT_SECRET: {{ .Values.secrets.adminJwtSecret | quote }}
ADMIN_SECRETS_MASTER_KEY: {{ .Values.secrets.adminSecretsMasterKey | quote }}
AZURE_AD_TENANT_ID: {{ .Values.secrets.azureAdTenantId | quote }}
AZURE_AD_CLIENT_ID: {{ .Values.secrets.azureAdClientId | quote }}
AZURE_AD_CLIENT_SECRET: {{ .Values.secrets.azureAdClientSecret | quote }}
AWS_ACCESS_KEY_ID: {{ .Values.secrets.awsAccessKeyId | quote }}
AWS_SECRET_ACCESS_KEY: {{ .Values.secrets.awsSecretAccessKey | quote }}
AWS_SESSION_TOKEN: {{ .Values.secrets.awsSessionToken | quote }}
AZURE_OPENAI_API_KEY: {{ .Values.secrets.azureOpenaiApiKey | quote }}
AZURE_STORAGE_CONNECTION_STRING: {{ .Values.secrets.azureStorageConnectionString | quote }}
# Constructed here (not left to the app) so every service reads the exact
# same connection string — same lesson as a docker-compose shadowing bug
# hit on the VM path (a value defined in two places drifting apart).
DATABASE_URL: "postgresql+asyncpg://grss_user:{{ .Values.secrets.postgresPassword }}@postgres:5432/grss_db"templates/backend.yaml Deployment + Service
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
labels: &labels
app: backend
app.kubernetes.io/name: backend
app.kubernetes.io/instance: {{ .Values.clientName }}
app.kubernetes.io/part-of: klara-{{ .Values.clientName }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
replicas: 1
# Only one replica ever runs, so a surging rolling update (rather than
# killing the old pod first) is what avoids a request-dropping gap while
# the new pod runs its alembic migration and passes the startup probe.
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: backend
template:
metadata:
labels: *labels
spec:
automountServiceAccountToken: false
containers:
- name: backend
image: "{{ .Values.image.registry }}/klara-backend:{{ .Values.image.tag }}"
securityContext:
allowPrivilegeEscalation: false
command: ["sh", "-c", "alembic upgrade head && gunicorn app:app -w 4 -k uvicorn_worker.UvicornWorker --bind 0.0.0.0:8000"]
ports:
- containerPort: 8000
envFrom:
- secretRef:
name: {{ .Values.clientName }}-secrets
env:
- name: APP_ENV
value: "production"
- name: APP_HOST
value: "0.0.0.0"
- name: APP_PORT
value: "8000"
# Kubernetes auto-injects a Docker-links-style FRONTEND_PORT
# (e.g. "tcp://10.x.x.x:4173") for the `frontend` Service in this
# namespace, which collides with config.py's own frontend_port
# int field and crashes Settings validation. Explicit values in
# the container spec win over that auto-injection — this is the
# fix, not a formality.
- name: FRONTEND_PORT
value: "4173"
- name: APP_DEBUG
value: "false"
- name: API_V1_PREFIX
value: "/api/v1"
- name: REDIS_URL
value: "redis://redis:6379/0"
- name: QDRANT_URL
value: "http://qdrant:6333"
- name: QDRANT_VECTOR_SIZE
value: "1536"
- name: ADMIN_BACKEND_INTERNAL_URL
value: "http://admin-backend:8080"
- name: LEGACY_TENANT_CODE
value: {{ .Values.clientName | quote }}
- name: AWS_REGION
value: {{ .Values.secrets.awsRegion | quote }}
- name: ANTHROPIC_MODEL
value: {{ .Values.secrets.anthropicModel | quote }}
- name: AZURE_STORAGE_CONTAINER_PREFIX
value: "grss-docs"
# Vector-search embeddings and rendered-layout vision QC both call
# Azure OpenAI directly — independent of ANTHROPIC_MODEL/AWS
# above. Left unset, both features silently degrade to a
# deterministic mock response (logged, not an error) rather than
# fail, so this whole block is optional.
- name: AZURE_OPENAI_ENDPOINT
value: {{ .Values.secrets.azureOpenaiEndpoint | quote }}
- name: AZURE_OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.clientName }}-secrets
key: AZURE_OPENAI_API_KEY
- name: AZURE_OPENAI_DEPLOYMENT
value: {{ .Values.secrets.azureOpenaiDeployment | quote }}
- name: AZURE_OPENAI_VISION_DEPLOYMENT
value: {{ .Values.secrets.azureOpenaiVisionDeployment | quote }}
- name: AZURE_OPENAI_EMBEDDING_DEPLOYMENT
value: {{ .Values.secrets.azureOpenaiEmbeddingDeployment | quote }}
- name: AZURE_OPENAI_API_VERSION
value: {{ .Values.secrets.azureOpenaiApiVersion | quote }}
- name: CORS_ORIGINS
value: '["https://{{ .Values.hostname }}"]'
- name: ENABLE_WORD_ONLINE
value: "false"
- name: ENABLE_SHAREPOINT
value: "false"
- name: ENABLE_WORD_COMMENTS
value: "false"
- name: ENABLE_TRACK_CHANGES
value: "false"
# alembic runs before gunicorn even binds the port, so nothing on
# 8000 answers until migrations finish. A startup probe absorbs
# that variable-length wait instead of a fixed initialDelay.
startupProbe:
httpGet:
path: /health/live
port: 8000
periodSeconds: 10
failureThreshold: 18
readinessProbe:
httpGet:
path: /health/ready
port: 8000
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/live
port: 8000
periodSeconds: 20
failureThreshold: 3
resources:
{{- toYaml .Values.resources.backend | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: backend
spec:
selector:
app: backend
ports:
- port: 8000
targetPort: 8000templates/admin-backend.yaml Deployment + Service
apiVersion: apps/v1
kind: Deployment
metadata:
name: admin-backend
labels: &labels
app: admin-backend
app.kubernetes.io/name: admin-backend
app.kubernetes.io/instance: {{ .Values.clientName }}
app.kubernetes.io/part-of: klara-{{ .Values.clientName }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: admin-backend
template:
metadata:
labels: *labels
spec:
automountServiceAccountToken: false
containers:
- name: admin-backend
image: "{{ .Values.image.registry }}/klara-admin-backend:{{ .Values.image.tag }}"
securityContext:
allowPrivilegeEscalation: false
ports:
- containerPort: 8080
envFrom:
- secretRef:
name: {{ .Values.clientName }}-secrets
env:
- name: SPRING_PROFILES_ACTIVE
value: "docker"
- name: DB_HOST
value: "admin-postgres"
- name: DB_PORT
value: "5432"
- name: DB_NAME
value: "klaradb"
- name: DB_USER
value: "klara"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.clientName }}-secrets
key: ADMIN_POSTGRES_PASSWORD
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.clientName }}-secrets
key: ADMIN_JWT_SECRET
# application.yml reads SECRETS_MASTER_KEY (no prefix) to encrypt
# stored SSO client secrets. Without this explicit remap, every
# deployment would silently fall back to application.yml's shared
# hardcoded default key — same class of bug as JWT_SECRET above.
- name: SECRETS_MASTER_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.clientName }}-secrets
key: ADMIN_SECRETS_MASTER_KEY
# AddinRevocationService calls this URL to revoke an add-in
# session on the core backend. Unset, it defaults to
# http://localhost:8000 — inside this pod that's admin-backend
# itself, not the core backend, so every revocation call fails
# with connection-refused.
- name: CORE_BACKEND_INTERNAL_URL
value: "http://backend:8000"
# app.frontend.base-url is the ADMIN CONSOLE's own origin — it
# drives admin-backend's CORS allowed-origin AND (via GET
# /api/config's adminBaseUrl) the unified sign-in's origin-match
# check. This is NOT the Word add-in's URL.
- name: APP_FRONTEND_BASE_URL
value: "https://{{ .Values.admin.hostname }}"
# The Word add-in's own hostname — separate from the above. Must
# be the real public hostname, or every SSO login (success and
# error both) silently redirects the browser to a localhost URL
# that only makes sense inside the container.
- name: ADDIN_FRONTEND_BASE_URL
value: "https://{{ .Values.hostname }}"
# Drives the "Tenant Admin" login option's redirect target.
{{- if .Values.webapp.enabled }}
- name: WEBAPP_FRONTEND_BASE_URL
value: "https://{{ .Values.webapp.hostname }}"
{{- end }}
# SsoRedirectPolicy (backend) rejects ANY SSO return redirect
# whose origin isn't in this list — including the plain fallback
# (APP_FRONTEND_BASE_URL) above, not just explicit redirects.
# Must list every public origin this deployment actually serves.
- name: SSO_ALLOWED_RETURN_ORIGINS
value: "https://{{ .Values.hostname }},https://{{ .Values.admin.hostname }}{{ if .Values.webapp.enabled }},https://{{ .Values.webapp.hostname }}{{ end }}"
- name: SHOW_SQL
value: "false"
- name: FORMAT_SQL
value: "true"
- name: LOG_SQL_LEVEL
value: "INFO"
- name: LOG_SQL_BIND_LEVEL
value: "OFF"
# Demo bootstrap tenant code/admin email are hardcoded in
# application.yml, so every namespace would otherwise seed an
# identical "klara-demo" tenant. Disabled here; real tenants get
# provisioned explicitly via POST /api/tenants/provision, which
# requires an authenticated session holding the platform.admin
# permission rather than a shared static key.
- name: BOOTSTRAP_ENABLED
value: "false"
# Flyway migrations run in-process before the JVM reports healthy;
# a startup probe absorbs that instead of a fixed initialDelay.
startupProbe:
httpGet:
path: /actuator/health
port: 8080
periodSeconds: 10
failureThreshold: 18
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
periodSeconds: 15
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
periodSeconds: 20
failureThreshold: 3
resources:
{{- toYaml .Values.resources.adminBackend | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: admin-backend
spec:
selector:
app: admin-backend
ports:
- port: 8080
targetPort: 8080templates/admin-frontend.yaml Deployment + Service
apiVersion: apps/v1
kind: Deployment
metadata:
name: admin-frontend
labels: &labels
app: admin-frontend
app.kubernetes.io/name: admin-frontend
app.kubernetes.io/instance: {{ .Values.clientName }}
app.kubernetes.io/part-of: klara-{{ .Values.clientName }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: admin-frontend
template:
metadata:
labels: *labels
spec:
automountServiceAccountToken: false
containers:
- name: admin-frontend
image: "{{ .Values.image.registry }}/klara-admin-frontend:{{ .Values.image.tag }}"
securityContext:
allowPrivilegeEscalation: false
ports:
- containerPort: 80
env:
- name: ADMIN_API_UPSTREAM
value: "http://admin-backend:8080"
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 15
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 20
failureThreshold: 3
resources:
{{- toYaml .Values.resources.adminFrontend | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: admin-frontend
spec:
selector:
app: admin-frontend
ports:
- port: 80
targetPort: 80templates/frontend.yaml Deployment + Service — the Word add-in
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
labels: &labels
app: frontend
app.kubernetes.io/name: frontend
app.kubernetes.io/instance: {{ .Values.clientName }}
app.kubernetes.io/part-of: klara-{{ .Values.clientName }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: frontend
template:
metadata:
labels: *labels
spec:
automountServiceAccountToken: false
containers:
- name: frontend
# NOTE: this image must be built per-deployment — FRONTEND_URL (and
# therefore manifest.xml + the JS bundle's API base) is baked in at
# `docker build` time, not read at runtime. See Part F.
image: "{{ .Values.image.registry }}/klara-frontend-{{ .Values.clientName }}:{{ .Values.image.tag }}"
securityContext:
allowPrivilegeEscalation: false
ports:
- containerPort: 4173
readinessProbe:
httpGet:
path: /taskpane.html
port: 4173
scheme: HTTPS
initialDelaySeconds: 10
periodSeconds: 15
livenessProbe:
httpGet:
path: /taskpane.html
port: 4173
scheme: HTTPS
initialDelaySeconds: 10
periodSeconds: 20
failureThreshold: 3
resources:
{{- toYaml .Values.resources.frontend | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: frontend
spec:
selector:
app: frontend
ports:
- port: 4173
targetPort: 4173templates/webapp.yaml Deployment + Service — skipped if webapp.enabled is false
{{- if .Values.webapp.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
labels: &labels
app: webapp
app.kubernetes.io/name: webapp
app.kubernetes.io/instance: {{ .Values.clientName }}
app.kubernetes.io/part-of: klara-{{ .Values.clientName }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: webapp
template:
metadata:
labels: *labels
spec:
automountServiceAccountToken: false
containers:
- name: webapp
image: "{{ .Values.image.registry }}/klara-webapp:{{ .Values.image.tag }}"
securityContext:
allowPrivilegeEscalation: false
ports:
- containerPort: 80
env:
# Runtime env var (nginx envsubst template), not baked at build
# time — unlike the Word add-in frontend, this image is shared.
- name: BACKEND_API_UPSTREAM
value: "http://backend:8000"
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 15
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 20
failureThreshold: 3
resources:
{{- toYaml .Values.resources.webapp | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: webapp
spec:
selector:
app: webapp
ports:
- port: 80
targetPort: 80
{{- end }}Data stores — self-hosted, one per deployment
templates/postgres.yaml StatefulSet + Service
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:15.10-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_USER
value: "grss_user"
- name: POSTGRES_DB
value: "grss_db"
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.clientName }}-secrets
key: POSTGRES_PASSWORD
# Azure Disk PVCs mount with a pre-existing `lost+found` dir at
# the root, which initdb refuses to initialize into ("directory
# exists but is not empty"). Initialize one level down instead.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "grss_user", "-d", "grss_db"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources.postgres | nindent 12 }}
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: {{ .Values.storage.storageClassName }}
resources:
requests:
storage: {{ .Values.storage.postgresSize }}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
clusterIP: Nonetemplates/admin-postgres.yaml StatefulSet + Service
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: admin-postgres
spec:
serviceName: admin-postgres
replicas: 1
selector:
matchLabels:
app: admin-postgres
template:
metadata:
labels:
app: admin-postgres
spec:
containers:
- name: admin-postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_USER
value: "klara"
- name: POSTGRES_DB
value: "klaradb"
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.clientName }}-secrets
key: ADMIN_POSTGRES_PASSWORD
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: admin-postgres-data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "klara", "-d", "klaradb"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources.adminPostgres | nindent 12 }}
volumeClaimTemplates:
- metadata:
name: admin-postgres-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: {{ .Values.storage.storageClassName }}
resources:
requests:
storage: {{ .Values.storage.adminPostgresSize }}
---
apiVersion: v1
kind: Service
metadata:
name: admin-postgres
spec:
selector:
app: admin-postgres
ports:
- port: 5432
targetPort: 5432
clusterIP: Nonetemplates/redis.yaml StatefulSet + Service
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
spec:
serviceName: redis
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /data
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources.redis | nindent 12 }}
volumeClaimTemplates:
- metadata:
name: redis-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: {{ .Values.storage.storageClassName }}
resources:
requests:
storage: "2Gi"
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
clusterIP: Nonetemplates/qdrant.yaml StatefulSet + Service
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: qdrant
spec:
serviceName: qdrant
replicas: 1
selector:
matchLabels:
app: qdrant
template:
metadata:
labels:
app: qdrant
spec:
containers:
- name: qdrant
image: qdrant/qdrant:v1.13.6
ports:
- containerPort: 6333
- containerPort: 6334
volumeMounts:
- name: qdrant-data
mountPath: /qdrant/storage
# Same lesson as the VM deployment: qdrant/qdrant has no wget/curl,
# so a wget- or curl-based probe reports unhealthy even when Qdrant
# is fine. Plain TCP check instead.
readinessProbe:
tcpSocket:
port: 6333
initialDelaySeconds: 10
periodSeconds: 10
resources:
{{- toYaml .Values.resources.qdrant | nindent 12 }}
volumeClaimTemplates:
- metadata:
name: qdrant-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: {{ .Values.storage.storageClassName }}
resources:
requests:
storage: {{ .Values.storage.qdrantSize }}
---
apiVersion: v1
kind: Service
metadata:
name: qdrant
spec:
selector:
app: qdrant
ports:
- name: http
port: 6333
targetPort: 6333
- name: grpc
port: 6334
targetPort: 6334
clusterIP: NoneIngress & routing — one per public hostname
templates/ingress.yaml Word add-in taskpane
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Values.clientName }}-ingress
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
# frontend's own nginx serves HTTPS on 4173 with a self-signed cert baked
# into the image (same as the VM deployment) — this tells the ingress
# controller to re-encrypt on that hop rather than assume plain HTTP.
# The REAL, trusted cert (issued by cert-manager) is what's actually
# presented to the browser; this cert only matters
# ingress-controller-to-pod, inside the cluster network.
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
spec:
ingressClassName: nginx
tls:
- hosts:
- {{ .Values.hostname }}
secretName: {{ .Values.clientName }}-tls
rules:
- host: {{ .Values.hostname }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 4173templates/ingress-webapp.yaml skipped if webapp.enabled is false
{{- if .Values.webapp.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Values.clientName }}-webapp-ingress
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
# No backend-protocol override here (defaults to HTTP) — webapp's nginx
# serves plain HTTP internally, unlike `frontend`'s HTTPS+self-signed
# cert. Kept as a separate Ingress object rather than a second rule on
# the main one, since that annotation applies per-Ingress, not per-path.
spec:
ingressClassName: nginx
tls:
- hosts:
- {{ .Values.webapp.hostname }}
secretName: {{ .Values.clientName }}-webapp-tls
rules:
- host: {{ .Values.webapp.hostname }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webapp
port:
number: 80
{{- end }}templates/ingress-admin.yaml admin console
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Values.clientName }}-admin-ingress
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
# No backend-protocol override here (defaults to HTTP) — admin-frontend's
# nginx serves plain HTTP internally, same as webapp. Separate Ingress
# object for the same reason as ingress-webapp.yaml: the annotation
# applies per-Ingress, not per-path.
spec:
ingressClassName: nginx
tls:
- hosts:
- {{ .Values.admin.hostname }}
secretName: {{ .Values.clientName }}-admin-tls
rules:
- host: {{ .Values.admin.hostname }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: admin-frontend
port:
number: 80Backup — optional, off with backup.enabled: false
templates/backup-cronjob.yaml restore path is untested — see Known gaps
{{- if .Values.backup.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ .Values.clientName }}-db-backup
spec:
schedule: {{ .Values.backup.schedule | quote }}
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
volumes:
- name: backup-data
emptyDir: {}
initContainers:
- name: dump
image: postgres:15.10-alpine
command:
- sh
- -c
- |
set -e
STAMP=$(date +%Y%m%d-%H%M%S)
PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h postgres -U grss_user -d grss_db \
-F c -f "/backup/grss_db-${STAMP}.dump"
PGPASSWORD="$ADMIN_POSTGRES_PASSWORD" pg_dump -h admin-postgres -U klara -d klaradb \
-F c -f "/backup/klaradb-${STAMP}.dump"
envFrom:
- secretRef:
name: {{ .Values.clientName }}-secrets
volumeMounts:
- name: backup-data
mountPath: /backup
containers:
- name: upload
image: mcr.microsoft.com/azure-cli:latest
command:
- sh
- -c
- |
set -e
for f in /backup/*.dump; do
az storage blob upload \
--connection-string "$AZURE_STORAGE_CONNECTION_STRING" \
--container-name "{{ .Values.backup.containerName }}" \
--name "{{ .Values.clientName }}/$(basename "$f")" \
--file "$f" \
--overwrite
done
env:
- name: AZURE_STORAGE_CONNECTION_STRING
value: {{ .Values.backup.azureStorageConnectionString | quote }}
volumeMounts:
- name: backup-data
mountPath: /backup
{{- end }}Build tooling — used once in Part F, not part of the chart itself
k8s/build-and-push.sh bash
#!/usr/bin/env bash
# Builds and pushes Klara's container images to a registry, tagging each
# with a real, traceable version (git short SHA by default) instead of a
# `latest` that silently drifts between deployments. Records what was pushed
# in k8s/versions.yaml so a deploy can pin to an exact, reproducible tag.
#
# Shared images — built once, used by every deployment (backend,
# admin-backend, admin-frontend, webapp):
# ./k8s/build-and-push.sh
# VERSION=2024.06.1 ./k8s/build-and-push.sh
#
# Per-deployment Word add-in image — FRONTEND_URL is baked in at build time,
# so this one is never shared across deployments:
# ./k8s/build-and-push.sh --client acme --hostname acme.yourcompany.com
#
# Requires: docker CLI already logged in to REGISTRY (e.g. `az acr login
# --name yourregistry`), and `git` for the default VERSION.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_DIR="$REPO_ROOT/KlaraApp"
VERSIONS_FILE="$REPO_ROOT/k8s/versions.yaml"
REGISTRY="${REGISTRY:-yourregistry.azurecr.io}"
VERSION="${VERSION:-$(git -C "$REPO_ROOT" rev-parse --short HEAD)}"
PUSH=1
CLIENT=""
CLIENT_HOSTNAME=""
API_BASE_URL="/api/v1"
CHAT_ENABLED="false"
usage() {
cat <<EOF
Usage:
$(basename "$0") # build+push the 4 shared images
$(basename "$0") --client <name> --hostname <host> # build+push one deployment's frontend image
Options:
--client <name> Build only the per-deployment Word add-in image for <name>.
--hostname <host> Public hostname baked into that deployment's bundle (required with --client).
--version <tag> Override the version tag (default: current git short SHA, or \$VERSION).
--api-base-url <url> Baked-in API base path (default: /api/v1).
--chat-enabled <bool> Baked-in chat feature flag (default: false).
--no-push Build locally only; skip the registry push and version-ledger update.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--client) CLIENT="$2"; shift 2 ;;
--hostname) CLIENT_HOSTNAME="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--api-base-url) API_BASE_URL="$2"; shift 2 ;;
--chat-enabled) CHAT_ENABLED="$2"; shift 2 ;;
--no-push) PUSH=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
esac
done
if [[ -n "$CLIENT" && -z "$CLIENT_HOSTNAME" ]]; then
echo "--hostname is required with --client (it's baked into the bundle at build time)" >&2
exit 1
fi
# Rewrites the `current.<key>: "..."` line in versions.yaml in place, then
# appends an entry to the history log. Only called for the 4 shared images —
# per-deployment images only get a history entry, never a `current` pointer,
# since each deployment's values file pins its own tag independently.
record_version() {
local key="$1" tag="$2" track_current="$3"
if [[ "$track_current" == "1" ]]; then
sed -i "s/^ ${key}: .*/ ${key}: \"${tag}\"/" "$VERSIONS_FILE"
fi
{
echo " - date: \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""
echo " component: ${key}"
echo " version: \"${tag}\""
echo " registry: \"${REGISTRY}\""
} >> "$VERSIONS_FILE"
}
build_and_push() {
local image="$1" dockerfile="$2" context="$3"
shift 3
echo "==> Building ${REGISTRY}/${image}:${VERSION}"
docker build -f "$dockerfile" -t "${REGISTRY}/${image}:${VERSION}" "$@" "$context"
if [[ "$PUSH" == "1" ]]; then
docker push "${REGISTRY}/${image}:${VERSION}"
fi
}
if [[ -n "$CLIENT" ]]; then
build_and_push "klara-frontend-${CLIENT}" "$APP_DIR/word-addin/Dockerfile.prod" "$APP_DIR/word-addin" \
--build-arg "FRONTEND_URL=https://${CLIENT_HOSTNAME}/" \
--build-arg "API_BASE_URL=${API_BASE_URL}" \
--build-arg "CHAT_ENABLED=${CHAT_ENABLED}" \
--build-arg "KLARA_SHARE_MODE=remote"
[[ "$PUSH" == "1" ]] && record_version "frontend-${CLIENT}" "$VERSION" 0
echo
echo "Pushed ${REGISTRY}/klara-frontend-${CLIENT}:${VERSION}"
echo "Set this deployment's values-${CLIENT}.yaml image.tag to \"${VERSION}\"."
exit 0
fi
# admin-frontend and webapp build from KlaraApp/ (not their own subfolder) —
# both Dockerfiles COPY the shared KlaraApp/shared/login-ui/ package, which
# sits outside a narrower context.
build_and_push "klara-backend" "$APP_DIR/backend/docker/Dockerfile" "$APP_DIR/backend"
[[ "$PUSH" == "1" ]] && record_version "backend" "$VERSION" 1
build_and_push "klara-admin-backend" "$APP_DIR/admin-console/backend/Dockerfile" "$APP_DIR/admin-console/backend"
[[ "$PUSH" == "1" ]] && record_version "adminBackend" "$VERSION" 1
build_and_push "klara-admin-frontend" "$APP_DIR/admin-console/frontend/Dockerfile" "$APP_DIR"
[[ "$PUSH" == "1" ]] && record_version "adminFrontend" "$VERSION" 1
build_and_push "klara-webapp" "$APP_DIR/web-app/Dockerfile" "$APP_DIR"
[[ "$PUSH" == "1" ]] && record_version "webapp" "$VERSION" 1
echo
if [[ "$PUSH" == "1" ]]; then
echo "Pushed all 4 shared images tagged ${VERSION} to ${REGISTRY}."
echo "Set image.tag: \"${VERSION}\" in the values file(s) ready to pick up this release."
else
echo "Built all 4 shared images tagged ${VERSION} locally (--no-push: nothing pushed, versions.yaml untouched)."
fik8s/versions.yaml starts empty — the script fills this in
# Version ledger for images pushed to the container registry.
#
# `current` is what a new deployment's values.yaml (image.tag) or an
# existing deployment's upgrade should point at. It is rewritten in place by
# k8s/build-and-push.sh on every successful push — do not hand-edit it.
#
# `history` is an append-only log of every build-and-push.sh run, oldest
# first. Safe to trim if it gets long; nothing reads it back.
#
# Per-deployment Word add-in images (klara-frontend-<name>) are not tracked
# under `current` since each deployment pins its own tag in its own
# values-<name>.yaml — they only ever show up in `history` below.
current:
backend: ""
adminBackend: ""
adminFrontend: ""
webapp: ""
history: []Quick reference
| What | Where |
|---|---|
| Helm chart | deploy/helm/klara-client/ |
| Your settings + secrets | deploy/helm/values-<your-name>.yaml — never committed |
| Example settings file | deploy/helm/values-clientname.example.yaml |
| Cluster-wide TLS issuer (edit the email first) | deploy/helm/cluster-issuer.yaml |
| Image build/push script | k8s/build-and-push.sh |
| Record of pushed image versions | k8s/versions.yaml |
| Deeper operator notes & known gotchas | deploy/helm/README.md |