Deployment Options (AKS)
How to change what runs at portal.example.com, from "instant, no redeploy" to "full image release". Each option below is independent — pick the lightest one that does the job. This page exists because several of the options have non-obvious constraints (a private API server, CI-only image builds, and a master key that must not be overwritten).
The environment, in one table
| Fact | Value | Consequence |
|---|---|---|
| Cluster | <aks-cluster> / rg <aks-resource-group> (Sweden Central) |
— |
| API server | private (…privatelink…azmk8s.io) |
kubectl/helm from outside the VNet cannot reach it — use az aks command invoke or an in-VNet runner/VPN |
| Key Vault CSI | azureKeyvaultSecretsProvider add-on enabled — its identity clientId comes from az aks show … --query addonProfiles.azureKeyvaultSecretsProvider.identity.clientId |
secrets can come from Key Vault, keyless |
| Key Vault | <key-vault> |
holds AzureFoundry-ApiKey |
| Images | Live pods pull from ACR meshweaver.azurecr.io. The chart's default is ghcr.io/systemorph/memex-portal-ai:latest, which the AKS deploy script repoints to ACR |
Two CI channels build them, plus a manual path — see below |
| portal-ai base | bakes @anthropic-ai/claude-code + @github/copilot CLIs |
the claude CLI is present in the running pod |
| AI model picker | fed by ModelProvider / LanguageModel mesh nodes |
see Setting Up Model Providers |
Option A — Live config via mesh nodes (no redeploy) ✅ used now
The model picker reads ModelProvider + LanguageModel mesh nodes. Create/patch them through the portal (MCP / Settings) and they take effect immediately — the data lives in Postgres, the running pod serves it, no deploy.
- The shared DeepSeek tiers live at
Systemorph/Provider/AzureFoundry(+ children); the key is stored encrypted (enc:v1:…, AES-256-GCM) on that node, decrypted in-process at request time. - Per-user providers (e.g. Claude Code) live at
{user}/_Memex/…and merge into the picker via the user's{user}/_Memex/Selection.
Best for: adding/curating models, fixing an empty picker, per-user keys. Limitation: these are instance nodes in a space/user partition — not the default catalog served to every partition (that's Option B). The encrypted key depends on the master key staying constant (see the caveat).
Option B — Default static catalog via Helm config (needs redeploy)
BuiltInLanguageModelProvider materialises a default catalog at Provider/{provider} + nested model children from config ({Section}:Models / :Endpoint) — imported into the top-level Provider partition on boot and served from the DB. The tier→model map now lives on the model NODES ("tier": "coding" — see Model Tiers); the ModelTier:Heavy/Standard/Light/Utility keys below are the deprecated shim, still read so an existing deployment keeps its mapping. The AKS overlay (deploy/aks/values.aks.yaml) sets:
AzureFoundry__Endpoint = https://<foundry-account>.services.ai.azure.com/models
AzureFoundry__Models__0/1/2 = DeepSeek-V4-Pro, DeepSeek-V3-0324, DeepSeek-V4-Flash
ModelTier__Heavy/Standard/Light/Utility = V4-Pro / V3-0324 / V4-Flash / V4-Flash
ClaudeCode__ConfigDirRoot = /mnt/users
The chart templates these in deploy/helm/templates/memex-portal/config.yaml (+ secrets.yaml); base values.yaml defaults them empty (neutral chart). Takes effect on the next helm upgrade (Option E), not before.
Best for: the org-wide default catalog + agent tier mapping. Anthropic/Claude is intentionally not wired here — Claude is per-user Claude Code (Option D).
Option C — Secrets via Key Vault + CSI (keyless, no committed keys)
The real AzureFoundry key lives in Key Vault, mounted by the CSI add-on and synced into a K8s Secret the portal reads via envFrom.
- Store the secret:
az keyvault secret set --vault-name <key-vault> --name AzureFoundry-ApiKey --value <key>. - Grant the add-on identity read access (once):
az role assignment create --assignee <csi-addon-identity-clientId> --role "Key Vault Secrets User" --scope <key-vault resourceId>(or an access policy with secret get/list). - Declare it under
keyVaultSecrets:in the environment's values (vaultName,tenantId,identityClientId, andsecrets: [{vaultSecret: AzureFoundry-ApiKey, key: AzureFoundry__ApiKey}]) — the chart renders theSecretProviderClass, the CSI volume, its mount and theenvFrom, so steps 3–4 of the legacy path below are no longer hand-applied. See DeploymentAKS → "Key Vault secrets are DECLARED in values". (Legacy, hand-made shape — template atdeploy/aks/envs/example/secretproviderclass.yaml; the real one is per-environment and git-ignored.) - (Legacy only) Patch the portal Deployment to mount it + read the synced secret:
# strategic-merge patch on the portal container/pod
spec:
template:
spec:
containers:
- name: memex-portal
envFrom:
- secretRef: { name: memex-portal-ai-secrets } # synced by the SPC
volumeMounts:
- name: kv-ai-secrets
mountPath: /mnt/secrets-store
readOnly: true
volumes:
- name: kv-ai-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: memex-portal-ai-secrets
Best for: keeping the key out of git/values. Pairs with Option B (B says which models; C supplies the key).
Option D — Claude Code (per-user, Claude on your own subscription)
Claude is not a shared org key. Each user connects the co-hosted Claude Code CLI under their own account in Settings → Models → Connect: the portal runs claude setup-token under {ClaudeCode:ConfigDirRoot}/{userId}/.claude, captures the token, and stores an encrypted {user}/_Memex/ClaudeCode provider that injects Claude into that user's picker.
claude setup-tokenrenders an Ink (terminal) UI that needs a real PTY; the login now runs under a pseudo-terminal (script -qfc "claude setup-token" /dev/null) whenClaudeConnect:UsePseudoTerminalis on (defaulted on for the Linux portal). This is a code change → ships via a new image (Option E).- Requires
ClaudeCode__ConfigDirRoot=/mnt/users(set in the AKS overlay) and the/mnt/usersRWX share (already mounted).
Option E — Code changes → new image → deploy
Anything in .cs (e.g. the Claude Code PTY fix, the static-catalog behaviour) only ships in a new image:
- Build + push. Three paths, and the normal one is the first:
- Continuous (default): merge to
main→main-cd.ymlbuilds and pushes3.0.0-ci.<n>to ACR (meshweaver.azurecr.io). This is what the portals self-update onto. It also runs on an hourly reconcile schedule andworkflow_dispatch. - Official release: push an annotated
v*.*.*tag on a promoted, sealed commit →release.ymlretags that set with the clean version in ACR and mirrors it to GHCR. Nothing is rebuilt (Release Process). - Manual (break-glass):
dotnet publish -t:PublishContainerstraight to ACR — see DeploymentAKS.md §1. 🚨 Only usable with the self-updater paused, because a non-SemVer tag is not a self-update candidate and the poller will roll the Deployment back off it.
- Continuous (default): merge to
- Roll the cluster to it — as a
Roll(image) orHelmRelease deploy/Reconcile(config)Hosting/InstanceActionon the control instance (OperatingFromThePortal). The commands below are what the operator runs; typed by hand they are break-glass, server-side viaaz aks command invokebecause the API server is private:
# config/manifest changes (works against the CURRENT image). The SecretProviderClass is
# per-environment and NOT in this repo — take it from the environment's own folder (template:
# deploy/aks/envs/example/secretproviderclass.yaml):
az aks command invoke -g <aks-resource-group> -n <aks-cluster> \
--command "kubectl apply -f secretproviderclass.yaml && kubectl rollout restart deploy/memex-portal-deployment -n <ns>" \
--file <env-dir>/secretproviderclass.yaml
# helm upgrade (uploads the chart + values to the in-cluster run pod, which has helm):
az aks command invoke -g <aks-resource-group> -n <aks-cluster> \
--command "helm upgrade <release> ./helm -f ./helm/values.yaml -f values.aks.yaml -n <ns>" \
--file deploy/helm --file deploy/aks/values.aks.yaml
az aks command invoke runs the command from a pod inside the cluster (kubectl + helm preinstalled) and attaches the --file paths — the standard way to operate a private AKS cluster without VNet line-of-sight.
🚨 The master-key caveat
ModelProvider.ApiKey values are encrypted with Ai:KeyProtection:MasterKey. The running deployment already has a master key set out-of-band (not in the chart, not in the KVs above). Therefore:
- The chart emits each AI secret key only when non-empty (
secrets.yamlguards), so an empty value never overrides what's set out-of-band. - Never deploy a different
Ai:KeyProtection:MasterKey— doing so makes every storedenc:provider key (including the liveSystemorph/Provider/AzureFoundrykey) undecryptable. If you ever manage it via the chart/Key Vault, point it at the exact same value the deployment already uses.
Quick chooser
| You want to… | Option | Redeploy? |
|---|---|---|
| Add/curate models or fix an empty picker now | A (mesh nodes) | no |
| Make a model the org-wide default + agent tier | B (Helm config) | yes |
| Keep the key out of git | C (Key Vault + CSI) | yes |
| Let users use Claude on their own subscription | D (Claude Code) | code → E |
Ship a .cs change |
E (merge to main → CI image → self-update, or command invoke) |
yes |
Related
- Configuring an instance from Aspire — the Deployment record is the ONE input: every option on this page that is a value the portal reads is a field on the record (or an
extraPortalConfigkey), rendered by Helm and by Aspire alike - Setting Up Model Providers — the node model + which query goes where
- AI Provider Configuration — credential/endpoint wiring + factory routing