Onboarding a New Environment
A "new environment" is an additional Memex portal β its own domain, database, and
sign-in β running on the shared AKS cluster (<aks-cluster> / <aks-resource-group>,
swedencentral).
π¨ This page is the MECHANISM, not the runbook. Per-environment folders moved OUT of this repository on 2026-08-08/09 (commit
a69959165) β their directory names are tenant identities. The maintained, ordered operator procedure lives in the privateSystemorph/Memexrepository (docs/new-deployment.md, with the inventory indeployments/aks/envs.json), and that copy is authoritative wherever this page and it disagree. What remains here is the part that is genuinely public: what is shared vs separate, the chart's config pass-through rule, the self-update model, and the gotchas. The only env folder still in this repository is the reference templatedeploy/aks/envs/example/, kept as a shape to copy β not as the place a real environment lives.
The shared platform (cluster, ingress, Postgres server, Key Vault, ACR) is brought up once β see
the AKS deployment sample (deploy/aks/README.md in the repository); this guide adds an
environment on top of it.
Shared vs. separate
| Resource | Shared across envs | Separate per env |
|---|---|---|
| AKS cluster + node pools | β
<aks-cluster> |
|
| Ingress controller (app-routing nginx, one public IP) | β | |
| Postgres server | β
<pg-server> |
database (<database>, β¦) |
| Key Vault | β
Systemorph |
secret names (<env>-*) + master key |
| ACR + portal image | β
meshweaver.azurecr.io/memex-portal-ai |
image tag (a commit sha) |
| Kubernetes namespace | β
<env> |
|
| Public host + TLS cert | β
<host> + <env>-tls |
|
| Entra app (sign-in) | β its own app registration |
1. Scaffold the env folder
Copy the reference template deploy/aks/envs/example/ β the real env folder is created in the
private ops repository, not here (see the note above). These are the files it contains and what
each one carries:
| File | What to change |
|---|---|
values.<env>.yaml |
host, MEMEX_DATABASENAME, TLS secretName, AI + auth config, resources, selfUpdate.azureClientId (the shared portalIdentityClientId β same value for every env) |
portal-pvcs.yaml |
namespace: <env> on every PVC |
portal-ingress.yaml |
namespace, host, TLS secret, affinity cookie name |
secretproviderclass.yaml |
legacy β declare the secrets under keyVaultSecrets: in values.<env>.yaml instead (the chart renders the SecretProviderClass, the CSI volume, its mount and the envFrom; see DeploymentAKS β "Key Vault secrets are DECLARED in values") |
portal-patch.json |
(usually unchanged β binds PVCs; the CSI secret mount + envFrom now render from keyVaultSecrets) |
deploy.sh / tls.sh |
NS, RELEASE, host |
values.<env>.yamlandsecretproviderclass.yamlare git-ignored (seedeploy/aks/envs/.gitignore) β they carry deployment-specific ids/sender/KV refs and are managed out-of-band. The scripts read them from disk.
2. Provision Azure (control-plane; no cluster access needed)
RG=<aks-resource-group>; PG=<pg-server>; KV=Systemorph; ZONE=meshweaver.cloud
INGRESS_IP=$(az aks command invoke -g $RG -n <aks-cluster> \
--command "kubectl get svc -n app-routing-system nginx -o jsonpath='{.status.loadBalancer.ingress[0].ip}'" --query logs -o tsv | tr -d '\r\n ')
# 1. Database on the shared server
az postgres flexible-server db create -g $RG -s $PG -d <env>
# 2. DNS A-record -> the SHARED ingress IP
az network dns record-set a add-record -g dns -z $ZONE -n <sub> --ipv4-address "$INGRESS_IP" --ttl 300
# 3. Sign-in app (MULTI-TENANT so any org can sign in; invitation-only gates access)
az ad app create --display-name "<Env> Portal (<host>)" \
--sign-in-audience AzureADMultipleOrgs \
--web-redirect-uris "https://<host>/signin-microsoft"
az ad app credential reset --id <appId> --display-name <env> --years 1 # -> client secret
# 4. KV secrets. FRESH master key only for an EMPTY db; for a MIGRATED db REUSE the
# source's master key (else stored enc: provider keys become undecryptable).
az keyvault secret set --vault-name $KV --name <env>-Ai-KeyProtection-MasterKey --value "$(openssl rand -base64 32)"
az keyvault secret set --vault-name $KV --name <env>-Authentication-Microsoft-ClientSecret --value "<entra-secret>"
# 5. Self-update (ACR polling): federate the SHARED portal UAMI to THIS namespace's memex-portal-sa
# so the in-pod self-updater can list ACR tags. Preferred: add the namespace to `portalNamespaces`
# in infra/main.bicep and re-run the (idempotent) infra deploy. Quick out-of-band equivalent:
ISSUER=$(az aks show -g $RG -n <aks-cluster> --query oidcIssuerProfile.issuerURL -o tsv)
az identity federated-credential create -g $RG --identity-name <portal-identity> \
--name "memex-portal-<env>" --issuer "$ISSUER" \
--subject "system:serviceaccount:<env>:memex-portal-sa" --audience "api://AzureADTokenExchange"
# The shared UAMI already has AcrPull on meshweaver.azurecr.io β set its clientId as
# selfUpdate.azureClientId in values.<env>.yaml (same value as every other env):
PORTAL_MI_CLIENT_ID=$(az identity show -g $RG -n <portal-identity> --query clientId -o tsv); echo "$PORTAL_MI_CLIENT_ID"
<env>is the Kubernetes namespace. The federated-credential subject must be EXACTLYsystem:serviceaccount:<env>:memex-portal-saβ a mismatch silently fails the ACR token exchange (the in-pod deployment PATCH still works; only tag discovery is blocked). See DeploymentAKS β Portal self-update.
3. Deploy + issue TLS
π¨ The connection host is the server FQDN, not a private IP. The servers moved on 2026-08-24, and a stale private-IP address here would revert that migration. The FQDN is safe with a password:
AzurePostgres.UsesManagedIdentityAuth(MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/AzurePostgres.cs) takes the Entra-token path only when the host carries the Azure suffix AND no password is present, so FQDN + password deliberately takes plain Npgsql withSslMode=Requireβ pinned byAzurePostgresAuthSelectionTests.
STAGE=$(mktemp -d); cp <env-folder-from-the-private-ops-repo>/* "$STAGE"/; cp -r deploy/helm "$STAGE"/helm
export MEMEX_PG_CONN='Host=<pg-server>.postgres.database.azure.com;Port=5432;Username=memexadmin;Password=<PW>;Database=<env>;SslMode=Require;Trust Server Certificate=true'
export IMAGE_TAG=<sha>
( cd "$STAGE" && az aks command invoke -g <aks-resource-group> -n <aks-cluster> \
--command "MEMEX_PG_CONN='$MEMEX_PG_CONN' IMAGE_TAG='$IMAGE_TAG' bash deploy.sh" --file . )
# Verify BEFORE DNS/TLS (host still unresolved or pointing elsewhere):
curl -sS -k -o /dev/null -w "%{http_code}\n" --resolve <host>:443:$INGRESS_IP https://<host>/
# Then issue the cert (needs the A-record to resolve publicly):
( cd <env-folder-from-the-private-ops-repo> && az aks command invoke -g <aks-resource-group> -n <aks-cluster> --command "bash tls.sh" --file tls.sh )
Self-update: first-install checklist
A new environment should run on self-update from day one β that is the steady state.
A new instance is a Deployments/<name> record provisioned by a Provision Hosting/InstanceAction
on the control instance (see /new-deployment
and OperatingFromThePortal); the az/kubectl steps on
this page are the pre-record procedure, kept as the record of how the existing environments were
built and as break-glass. The manual AKS runbook (kubectl set image + rollout) is what a Roll action runs, and the break-glass path only. Once per
environment, in this order:
- Deploy the
portal-identitybicep. It provisions the shared portal UAMI (<portal-identity>) + one federated credential per portal namespace (deployPortalIdentity: true, default). For a brand-new namespace, add it toportalNamespacesand re-run the (idempotent) infra deploy, or create the federated credential out-of-band (Β§2 step 5). The subject must be exactlysystem:serviceaccount:<env>:memex-portal-sa. - Grant the portal UAMI
AcrPullon the shared ACR.meshweaver.azurecr.iolives inmeshweaver-sharedβ cross-RG from<aks-resource-group>β so grant it out-of-band exactly like the kubelet's grant (or setgrantSharedAcrPull=truefor pure-IaC). One grant covers every namespace (one shared UAMI). Without it the in-pod Deployment PATCH still works; only ACR tag discovery is blocked. - Set
selfUpdate.azureClientIdinvalues.<env>.yamlto the sharedportalIdentityClientId(the same value for every env). This authenticates the tag-list call; the chart wires the workload-identity annotation/label +AZURE_CLIENT_IDfrom it. - Set
Admin/UpdatePolicyfor the env. Settings β Updates (platform admin) writes theAdmin/UpdatePolicynode. The seeded default is Stable (rolls only to the newest clean release). For dev/test set Continuous with a pattern β3.0.0-ci*today β so the install follows that line's build-numbered images;Continuouswithout a pattern is Stable. A new install can seed that from the chart:SelfUpdate__DefaultPolicy=Continuous+SelfUpdate__DefaultPattern=3.0.0-ci*. See Release & Self-Update Strategy. - Add the env's Azure Files share to the CI bake targets β otherwise no published bundle ever
reaches the new portal and its pods Roslyn-compile every shipped NodeType at boot. Append its
<account>/<share>[/<base-path>](the account/share behind the namespace'smemex-dataPVC) to theBAKE_PUBLISH_TARGETSrepo variable on the platform repo and on each satellite content repo, and make sure thegithub-actions-bakeidentity holds Storage File Data Privileged Contributor on that storage account. The publishing jobs preflight this red, never skipped β see The Continuous Delivery Contract, which also carries the GitHub OIDC subject-format rule (register BOTH the classic and the immutable subject per repo). Note that identity's federated subjects come from GitHub's issuer and are unrelated to the cluster-issuersystem:serviceaccount:subjects in steps 1β3.
Plugins β wire the environment to a registry
A new environment starts with no plugins, and its Settings βΈ Administration βΈ Plugin Catalog tab reads "not configured" until you point it at a registry. Plugins live in (usually private) git repos; one installation is the registry β it alone holds the git credential and re-serves the catalog over HTTP β and every other installation is a credential-free consumer.
Consumer (the normal case) β in values.<env>.yaml:
pluginCatalog:
registryUrl: "https://<registry-host>" # or `registries: [{name, url, ref}]` for several
config:
memex_portal:
PluginCatalog__AutoUpdateByDefault: "true" # installs track their repo; install-time seed only
secrets:
memex_portal:
PluginCatalog__RegistryToken: "<token issued to this installation>"
The consumer's token is the mwi_ instance key issued when the new installation is registered
on the registry portal (Settings βΈ Instances β self-service, shown once). Registration grants
nothing by itself; what the instance may pull is decided per (source, package) by a platform
admin on the registry β except sources the registry opted into PluginCatalog:DefaultGrants
(typically the platform Plugins/* repo), which every new registration is granted automatically.
So with defaults configured, a fresh environment needs no admin grant step to see the platform
plugins β install them from the Plugin Catalog tab once the consumer wiring lands.
Zero-touch alternative β auto-registration on first boot. Skip the register-and-copy step
entirely: set pluginCatalog.instanceId in the values file and the PluginCatalog__BootstrapKey
secret (an admin-minted mwr_ registration key from the registry's Instance grants βΈ Registration
keys section β one key serves the whole scaffold and is revocable). On first startup the portal
registers itself at the registry, receives its mwi_ key and stores it
(Admin/PluginRegistryCredential/β¦, encrypted with the master key); DefaultGrants then fill the
catalog with no admin involved. An explicitly configured PluginCatalog__RegistryToken always
wins over the stored credential.
Registry (only when this environment is the registry):
pluginCatalog:
sources:
- {name: Plugins, repoPath: "https://github.com/<org>/<plugins-repo>", ref: main}
defaultGrants: ["Plugins/*"] # granted to every NEW registration; never private/paid sources
secrets:
memex_portal:
PluginCatalog__RegistryTokens: ["<token-per-registered-installation>"]
π¨ A registry with an empty
RegistryTokenslist answers ANY anonymous caller with the full catalog and every package's file content β that is the local-dev / e2e stub mode. Always configure tokens on a production registry, and verify with an unauthenticatedcurl https://<registry-host>/api/plugins(want401). Prefer sourcing the token from Key Vault via the SecretProviderClass over putting it in the values file.
Sign-in, invitations, email
- Microsoft, multi-tenant. Set
Authentication__Microsoft__ClientId+ leave the tenant asorganizations(authorityβ¦/organizations/v2.0). The client secret comes from the Key Vault via the SecretProviderClass. Empty a provider'sClientId("") to hide it β that overrides the image's bakedappsettings.jsondefault (e.g. the inlined LinkedIn id). - Invitation-only (
Features__Onboarding__InvitationOnly=true): the first user (empty user table) always bootstraps to global admin β the gate exempts the first user, so the env can never lock itself out β then invites others. See Invitation-Only Onboarding. - Email (
Email__Enabled=true+ GraphMail.Sendapp): invitations email. The mailbox the portal sends and receives as (Email__MailboxAddress) must be a real mailbox in the tenant (meshweaver.cloudis not a mailbox domain;no-reply@systemorph.comdoes not exist β use a real/shared mailbox). The Graph app needs theMail.Sendapplication permission + admin consent (plusMail.ReadWriteif you also enable the inbound channel viaEmail__InboundEnabled=true).
Migrating an existing portal (data move)
For a portal moving off another platform (e.g. ACA β AKS), in addition to the above:
- Reuse the source master key in the env's KV (decrypts stored
enc:provider keys). - DB:
pg_dump --no-owner --no-aclthe source β restore into the env's database. The source may be Entra-auth only β dump from an in-cluster pod with an AAD token (an Entra admin on the source server) and a temporary firewall rule for the AKS egress IP. - Content: copy the blob content collection β the
/mnt/contentAzure Files share. - Verify on the ingress IP (
--resolve), then cut DNS over, keeping the old platform as rollback.
See Memex Cloud Deployment for the prod-grade specifics.
π¨ Gotchas (learned the hard way)
- The chart configMap only emits keys it templates.
deploy/helm/templates/memex-portal/config.yamlhas a fixed key list. AnyAuthentication__*/Features__*/Email__*/OTEL_*value in your env overlay is silently dropped unless the template passes it through. Symptom: the Microsoft button never renders (noMicrosoft:ClientIdreaches the portal). - Never emit an empty string for an int/bool config key.
Anthropic__Order: ""failsInt32binding βAzureClaudeChatClientAgentFactorythrows on DI activation β the chat page (the post-onboarding landing) dies with "exception thrown while activating IChatClientFactory[]". In the chart, default typed keys to a valid value (Orderβ"0", bools β"false"), never"". - The Key Vault (CSI) secret must be LAST in the container's
envFrom. The chart'smemex-portal-secretscarries an emptyAuthentication__Microsoft__ClientSecret; the CSI-synced<env>-portal-ai-secretscarries the real one. LaterenvFromwins, so the CSI secret must come after. Symptom:AADSTS7000218(token request had noclient_secret). - The post-helm
portal-patch.jsonis not idempotent.helm upgradecan preserve kubectl-added volumes, so re-applying the patch fails on duplicate volume adds β which rejects the whole atomic patch, dropping theenvFromCSI secret. After a redeploy, re-verify the portal'senvFromincludes<env>-portal-ai-secretsand the data/users volumes are PVCs. - Observability is already on. Grafana + Loki + Promtail run in the
monitoringnamespace and scrape every namespace β query{namespace="<env>"}in Grafana Explore (reach it via the P2S VPN +kubectl -n monitoring port-forward svc/loki-grafana 3000:80). Loki retains logs across pod restarts, unlikekubectl logs. - π©Ί Crash dumps need the MOUNT, not just the env vars.
DOTNET_DbgEnableMiniDump+DOTNET_DbgMiniDumpName=/data/dumps/β¦are worthless on their own:createdumpdoes not create directories, so without a volume mounted at that path every crash fails with "Could not create output file β¦ No such file or directory" β destroying its own evidence β and burns ~6s plus a ~350k-line log storm on the way down. The chart mounts a dedicatedmemex-dumpsemptyDir there; an env whose live pod lacks it produces zero dumps while looking fully instrumented. Verified 2026-07-28: all three environments had the env vars pointing at a non-existent directory, so every productionexit=139since had left nothing to analyse. Check the MOUNT, never the env:kubectl get deploy memex-portal-deployment -n <env> \ -o jsonpath='{range .spec.template.spec.containers[0].volumeMounts[*]}{.name} -> {.mountPath}{"\n"}{end}' \ | grep dumps || echo "NO DUMP MOUNT β crashes will produce nothing" - The per-env patch is index-SENSITIVE, and fails loudly when the chart moves. The data/users/content
PVC volumes now render from the chart (
persistence:invalues.aks.yaml);portal-patch.jsononly appends the env-specific extras, using"path": ".../volumes/-"(append) rather than a numeric index. It does, however, open with a JSON-Patchtestguard β{"op":"test","path":"/spec/template/spec/volumes/0/name","value":"memex-data"}β so if the chart ever reorders volumes, the whole atomic patch is rejected rather than silently misapplied. That is the intended behaviour: a failed patch is the signal. Read the rejection as "the chart's volume order changed", not as a transient error, and still verify the live mounts after a deploy. - KEDA overrides
kubectl scale. AScaledObjectwithminReplicaCount: 2silently restores replicas, so "scale to zero" (the documented heal for a wedged mesh) does not take and you conclude the heal failed when it never ran. Check first β and notePAUSED:kubectl get scaledobject -n <env> - HTTP 200 proves nothing. The Blazor shell returns 200 for a page that renders an error, a
paywall redirect, or nothing at all. Verify a deploy with response headers for static assets
and with actual rendered content (
get @<Node>/area/<Area>through the MCP, or a real browser) β never with a status code.
Verifying a rollout (and what "normal turbulence" looks like)
A new image means fresh pods, and every dynamic NodeType's cached assembly is ABI-stale against
the new framework build β so they all recompile. Expect a window where pages and /api/content/β¦ return
errors like "No response received β¦ for request GetDataRequest/SubscribeRequest β target X".
That is cold-compile, not a bad image.
# 1. BEFORE rolling: the manifest must have every arch leg. A partial manifest list is an
# ImagePullBackOff on the missing arch, which reads as "the deploy hung".
az acr manifest show -r <registry> -n memex-portal-ai:<tag> \
| jq -r '.manifests[]?.platform | "\(.os)/\(.architecture)"'
# 2. Roll and wait. The cluster is PRIVATE β these kubectl lines only work from inside it, so in
# practice wrap them: az aks command invoke -g <rg> -n <cluster> --command "β¦"
kubectl set image deploy/memex-portal-deployment memex-portal=<registry>/memex-portal-ai:<tag> -n <env>
kubectl rollout status deploy/memex-portal-deployment -n <env> --timeout=600s
# 3. Verify with real signals, in a LOOP (one probe can hit a warm pod and lie).
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " https://<host>/api/content/<space>/content/<file>; done
- Do not cycle pods while it warms. Deleting or scaling pods restarts the compile work from cold and makes the window longer, not shorter.
- A steady ratio is a signal, not warm-up. Warm-up converges toward 100%; a stable ~50% across many minutes means one replica is failing consistently β investigate that, do not wait it out.
- Probe budgets. Liveness (
/alive, 15s Γ 6 = 90s) and readiness (/alive, 10s Γ 3 = 30s) do not run until the startup probe succeeds, so a slow boot is safe. What is not safe is a hang or crash after startup β 90s of failed/aliveand kubelet restarts the container.- π¨ The startup budget is environment-specific β don't quote the chart default. It is
probes.startup.periodSeconds Γ failureThreshold; the base chart defaults to 5 Γ 60 = 5 minutes, but the AKS overlay (deploy/aks/values.aks.yaml) sets 10 Γ 1080 = 3 hours. The large budget is deliberate: it is what bounds a cold NodeType bake (PreWarm__GateReadiness), which is sequential and ~90 s per type. So on AKS a pod can sit un-ready far longer than "the deploy hung" instincts suggest β checkprobes:in the env's values before concluding anything from elapsed time.
- π¨ The startup budget is environment-specific β don't quote the chart default. It is
Related
- AKS deployment sample β
deploy/aks/README.mdin the repository β the one-time shared-platform bring-up (the formerDEPLOY-RUNBOOK.mdwas folded into it). - Memex Cloud Deployment Β· Deployment Options
- Invitation-Only Onboarding Β· Feature Flags