Klara · Kubernetes Setup & Deployment Guide

Self-Hosting Klara on Kubernetes

Everything needed to run the full Klara stack on your own Azure Kubernetes Service (AKS) cluster — from provisioning the cluster to your first login — using the Helm chart already in this codebase.

Delivered as: Helm chart Runs on: your own AKS cluster Registry: your own Azure Container Registry

Running this on a single server instead? See the Docker Compose guide — simpler, no cluster required.

In this codebase — a real file, chart, or command you already have You'll set up — Azure infrastructure this repo doesn't script for you

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.

HostnameServes
klara.yourcompany.comWord add-in taskpane
klara-app.yourcompany.comTenant web app
klara-admin.yourcompany.comAdmin 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 git repository or archive
  • az (Azure CLI), docker, and kubectl installed 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.

A

Provision the AKS cluster and a registry

You'll set up

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.

bash
# 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)

Remember your registry name — you'll override the chart's default with it in Part G. The chart ships with 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.
B

Connect to the cluster

In this codebase
bash
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

C

Cluster-wide ingress & TLS

In this codebase

One-time setup for the whole cluster. Installs the shared ingress controller and the certificate issuer your deployment's TLS cert comes from.

bash
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.
Edit the certificate issuer before applying it. 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:
bash
kubectl apply -f deploy/helm/cluster-issuer.yaml

Get the ingress IP once this is up — every hostname in Part D points here:

bash
kubectl get service -n ingress-nginx ingress-nginx-controller

Source: deploy/helm/README.md, deploy/helm/cluster-issuer.yaml

D

Point DNS at the cluster

You'll set up

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

E

Get the code ready

In this codebase
bash
git clone <the repository URL you were given> klara
cd klara
F

Build and push your images

In this codebase

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.

bash
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

G

Deploy your instance

In this codebase

Create your values file

bash
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. acme
  • hostnameklara.yourcompany.com
  • webapp.hostnameklara-app.yourcompany.com
  • admin.hostnameklara-admin.yourcompany.com
  • image.registry<yourregistry>.azurecr.iorequired, the chart's own default points at Kanerika's registry, not yours
  • image.tag — the version string from Part F
  • Every REPLACE_ME under secrets: — generate each fresh with openssl rand -hex 32 (adminSecretsMasterKey wants openssl rand -base64 32)
Never commit this file. deploy/helm/values-*.yaml is gitignored on purpose — it holds real secrets. Only *.example.yaml is tracked.

Install

bash
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

H

Verify it's running

In this codebase
bash
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

I

First login — and locking down the default account

In this codebase

Every new instance comes with one built-in login, already active:

Tenantsystem
Emailsuperuser@klara.test
PasswordChangeMe123!

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.

One difference from the Docker Compose path: this chart's demo-tenant seeder (a separate, additional 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

J

Get the Word add-in to your users

In this codebase

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.xml and assign it to users or groups.
  • SharePoint App Catalog — upload manifest.xml there; 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.enabled in 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.

deploy/helm/ ├── cluster-issuer.yaml # cluster-wide, apply once ├── values-clientname.example.yaml # copy → values-<yours>.yaml └── klara-client/ # the chart itself ├── Chart.yaml ├── values.yaml # shared defaults, all charts start here └── templates/ ├── secrets.yaml ├── backend.yaml ├── admin-backend.yaml ├── admin-frontend.yaml ├── frontend.yaml ├── webapp.yaml ├── postgres.yaml ├── admin-postgres.yaml ├── redis.yaml ├── qdrant.yaml ├── ingress.yaml ├── ingress-webapp.yaml ├── ingress-admin.yaml └── backup-cronjob.yaml k8s/ # build tooling, used once in Part F ├── build-and-push.sh └── versions.yaml # generated — starts empty

Chart metadata & shared defaults

deploy/helm/klara-client/Chart.yaml
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
yaml
# 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
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
yaml
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: nginx

Application workloads

templates/secrets.yaml
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
yaml
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: 8000
templates/admin-backend.yaml Deployment + Service
yaml
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: 8080
templates/admin-frontend.yaml Deployment + Service
yaml
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: 80
templates/frontend.yaml Deployment + Service — the Word add-in
yaml
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: 4173
templates/webapp.yaml Deployment + Service — skipped if webapp.enabled is false
yaml
{{- 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
yaml
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: None
templates/admin-postgres.yaml StatefulSet + Service
yaml
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: None
templates/redis.yaml StatefulSet + Service
yaml
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: None
templates/qdrant.yaml StatefulSet + Service
yaml
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: None

Ingress & routing — one per public hostname

templates/ingress.yaml Word add-in taskpane
yaml
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: 4173
templates/ingress-webapp.yaml skipped if webapp.enabled is false
yaml
{{- 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
yaml
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: 80

Backup — optional, off with backup.enabled: false

templates/backup-cronjob.yaml restore path is untested — see Known gaps
yaml
{{- 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
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)."
fi
k8s/versions.yaml starts empty — the script fills this in
yaml
# 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

WhatWhere
Helm chartdeploy/helm/klara-client/
Your settings + secretsdeploy/helm/values-<your-name>.yaml — never committed
Example settings filedeploy/helm/values-clientname.example.yaml
Cluster-wide TLS issuer (edit the email first)deploy/helm/cluster-issuer.yaml
Image build/push scriptk8s/build-and-push.sh
Record of pushed image versionsk8s/versions.yaml
Deeper operator notes & known gotchasdeploy/helm/README.md