A reference, operator-facing deployment of the Memex portal on a private
Azure Kubernetes Service cluster. It layers AKS-specific Azure infrastructure
(Bicep) and a Helm values overlay on top of the generic Kubernetes chart that
already lives at ../helm. Everything here is infra / YAML /
markdown only — no application code changes.
This is a sample, and it is self-contained: this one file is the whole AKS story — bring-up, ingress/TLS, the roll-out procedure, and the traps that have actually bitten production. Read it end-to-end, tune the parameters for your environment (regions, SKUs, CIDRs, DNS names, secrets), and treat the security defaults as a starting point, not a finished hardening.
Architecture decisions baked in:
- Private AKS API server + private Postgres Flexible Server; only the
portal is public (
:443). - One shared Azure Container Registry, reachable from every environment.
- Content on RWX Azure Files (
/mnt/content); mesh data in Postgres. - Blazor sticky sessions (cookie affinity), HA across replicas with Orleans AdoNet clustering.
- TLS via cert-manager + Let's Encrypt (HTTP-01).
| Concern | This sample's choice |
|---|---|
| Cluster | Private AKS (enablePrivateCluster=true) — API server has a private IP only |
| kubectl reach | az aks command invoke — runs kubectl/helm inside the cluster, no network line-of-sight. An optional Azure VPN Gateway (Point-to-Site) + linked private DNS zone privatelink.<region>.azmk8s.io gives interactive tooling a route into the VNet (Appendix A) |
| Registry | Azure Container Registry (Premium) with AcrPull granted to the cluster's kubelet identity |
| Networking | VNet with aks-nodes, GatewaySubnet, AzureBastionSubnet subnets; Azure CNI overlay + Cilium |
| Portal | Blazor Server, HA behind ingress with cookie session affinity; replicas owned by a KEDA ScaledObject (2 → 8 in this overlay), never by a kubectl patch |
| Shared storage | Azure Files (RWX) drives mounted at explicit paths: /data (caches), /mnt/content (content collection), /mnt/attachments, /mnt/users — via a custom azurefile-memex StorageClass tuned for the non-root portal (uid 1654) |
| Database | Azure Database for PostgreSQL Flexible Server, VNet-injected + private (default). A self-managed Postgres (pgvector) StatefulSet on a Premium-SSD PVC is the in-cluster alternative |
| Backup / PITR | pgBackRest → Azure Blob (full + diff CronJobs + WAL archiving) → restore --type=time |
| Observability | OpenTelemetry Collector DaemonSet captures cluster-wide pod logs + portal OTLP → Azure Files log archive (/mnt/otel-logs); Grafana + Loki + Promtail + Prometheus in monitoring for search and dashboards |
| Error ticketing | mw-log-watcher (optional, monitoring) reads red logs from Loki and opens one triaged GitHub issue per distinct fault — off until configured |
| Identity | Workload Identity (OIDC) so pgBackRest reaches Blob keyless |
| Instance lifecycle | provision → suspend → tear down driven from the mesh, with a verified pg_dump before anything is destroyed and an ingress-level paywall while suspended — see INSTANCE-LIFECYCLE.md |
┌──────────────── operator laptop ───────────────┐
│ az aks command invoke (primary — no tunnel) │
│ azure-vpn / OpenVPN client (OPTIONAL, Appx A) │
└───────────────────────┬─────────────────────────┘
│ P2S tunnel (172.16.201.0/24)
│ — optional; Grafana/psql only
┌────────────────────────────▼──────────────────────────┐
│ VNet 10.0.0.0/16 │
│ ┌── GatewaySubnet ──┐ ┌── aks-nodes 10.0.0.0/20 ──┐ │
│ │ VPN Gateway │ │ AKS node pool (3x, zonal) │ │
│ └───────────────────┘ │ ├ memex-portal x3 (RWX) │ │
│ privatelink.<rgn>. │ ├ memex-postgres (PVC) │ │
│ azmk8s.io ◄── private │ │ └ pgbackrest sidecar │ │
│ API server A record │ └ pgbackrest CronJobs │ │
│ └─────────────┬───────────────┘ │
└────────────────────────────────────────┼─────────────────┘
│ Workload Identity
ACR (AcrPull) Azure Blob ◄─┘ (WAL + backups, keyless)
deploy/aks/
├── README.md ← you are here — the whole AKS story
├── SELF-UPDATE.md ← how a running portal pulls its own new image
├── INSTANCE-LIFECYCLE.md ← provision → suspend → tear down, driven from the mesh
├── values.aks.yaml ← Helm overlay for ../helm (AKS overrides)
├── scripts/
│ ├── deploy.sh ← namespace + PVCs + helm upgrade, run via `command invoke`
│ ├── tls.sh ← cert-manager + Let's Encrypt issuer + ingress TLS
│ ├── install-observability.sh ← Grafana + Loki + Promtail + Prometheus (ns monitoring)
│ ├── import-dashboards.sh ← generic Grafana dashboard importer
│ ├── check-chart-invariants.sh ← PR gate: does the CHART describe a workable shape?
│ ├── check-chart-drift.sh ← does the CLUSTER run what the chart describes?
│ ├── aks-extras.yaml ← StorageClass + RWX PVCs, applied by deploy.sh
│ └── values.deploy.example.yaml ← template for your secrets file (never commit the real one)
├── infra/
│ ├── main.bicep ← subscription-scoped orchestrator (creates RG)
│ ├── main.parameters.json ← edit these
│ └── modules/
│ ├── network.bicep ← VNet + subnets + private DNS zone + VNet link
│ ├── acr.bicep ← Azure Container Registry
│ ├── aks.bicep ← PRIVATE AKS + identities + AcrPull + CSI + OIDC
│ ├── vpn.bicep ← P2S VPN Gateway (cert auth, OpenVPN/IKEv2)
│ ├── storage.bicep ← Blob storage + Workload Identity for pgBackRest
│ └── files.bicep ← Azure Files account + named shares for STATIC PV binding
└── manifests/ ← applied alongside the Helm release
├── storageclass-azurefile.yaml ← custom azurefile-memex SC (uid 1654, nobrl)
├── portal-pvcs.yaml ← RWX drives: data/content/attachments/users + pg PVC
├── portal-ha-patch.yaml ← LEGACY zone spread + probes; the chart now renders the
│ RWX volumes and owns the replica count — see Step 4
├── postgres-pvc-patch.yaml← LEGACY; the chart's StatefulSet has its own volumeClaimTemplate
├── portal-ingress.yaml ← ingress + cookie session affinity (Blazor)
├── observability/
│ ├── otel-collector-config.yaml ← collector pipeline (filelog+otlp → file+debug)
│ ├── otel-collector.yaml ← collector DaemonSet + SA/RBAC + Service
│ ├── otel-pvc.yaml ← RWX Azure Files PVC for the log archive
│ └── log-watcher.yaml ← mw-log-watcher Deployment + state PVC (optional)
└── pgbackrest/
├── serviceaccount.yaml← Workload-Identity SA for keyless Blob
├── configmap.yaml ← pgbackrest.conf (Azure repo) + WAL archive conf
├── sidecar-patch.yaml ← pgBackRest sidecar + WAL archiving wiring
└── cronjobs.yaml ← stanza-create Job + full/diff backup CronJobs
The chart at ../helm is generated from the Aspire model and is intentionally
generic (Azure-free, single replica, emptyDir volumes, no ingress). We do
not fork or regenerate it. Instead:
values.aks.yamlsets the keys the chart already consumes (config.*,secrets.*,persistence.*,keda.*,portal.image) with AKS-correct values (e.g. OrleansAdoNetclustering for HA).manifests/supplies the things the generic chart does not template — the custom StorageClass, the RWX PVCs themselves, ingress with sticky sessions, and the pgBackRest sidecar/CronJobs — askubectl applysteps aroundhelm install.
🚨 The overlay has been catching up to the chart, and the direction is one-way:
work keeps MOVING from manifests/ into the chart. persistence: and
replicas:/keda: used to be bolt-on kubectl patch steps and are now rendered
by ../helm itself, which is why manifests/portal-ha-patch.yaml and
manifests/postgres-pvc-patch.yaml are marked LEGACY above and are not part
of the install (Step 4 says why re-applying them is actively harmful). When you
find a patch file here, check whether the chart already does it before running it.
azCLI ≥ 2.84 (az version), logged in to the target subscription/tenantaz bicep≥ 0.41 (az bicep version) —az bicep upgradeif olderkubectlandhelm≥ 3.12- A subscription where you can create resource groups + role assignments (Owner or User Access Administrator — the deployment grants AcrPull, DNS, and Blob roles)
dockerand the .NET 10 SDK — only if you build the images yourself (Step 3, Option C) rather than pulling published ones- A DNS zone for your domain in Azure DNS (Step 4b creates the A record in it)
openssl— only for the optional P2S VPN certificates (Appendix A); WindowsNew-SelfSignedCertificateworks too
A globally-unique registry shared across environments, created once (skip if
you already have one, or if infra/modules/acr.bicep provisions a
per-deployment ACR for you):
az group create -n meshweaver-shared -l swedencentral
az acr create -g meshweaver-shared -n <acrName> --sku PremiumValidate the Bicep before deploying:
az bicep build --file infra/main.bicep --stdout > /dev/null # compiles cleanEdit infra/main.parameters.json (region, namePrefix, node SKU/count, CIDRs,
toggles). Then deploy at subscription scope (the template creates the
resource group):
az deployment sub create \
--name memex-aks-infra \
--location westeurope \
--template-file infra/main.bicep \
--parameters @infra/main.parameters.json \
--parameters postgresAdminPassword="$PG_ADMIN_PW" # required: deployPostgresFlexible=true
postgresAdminPasswordis a@secure()parameter — it is NOT inmain.parameters.json(never commit a DB password). Pass it at deploy time. If you setdeployPostgresFlexible: false(use the in-cluster StatefulSet instead), you can omit it.
This is infra only — it does not install the portal. Capture the outputs you need for later steps:
az deployment sub show --name memex-aks-infra \
--query "properties.outputs.{rg:resourceGroupName.value, cluster:clusterName.value, acr:acrLoginServer.value, vpn:vpnGatewayName.value, pgFqdn:postgresFqdn.value, pgDb:postgresDatabaseName.value, pgUser:postgresAdminLogin.value, filesAccount:contentFilesAccount.value, oidc:oidcIssuerUrl.value}" -o jsoncThe VPN Gateway takes 20–45 minutes to provision — this dominates the deploy time, and you do not need it to deploy. Step 2's
az aks command invokeis the primary path and needs no gateway, so setdeployVpnGateway: falseunless you specifically want the interactive VNet route described in Appendix A (Grafana port-forward,psql). It defaults totrue(infra/main.bicep,param deployVpnGateway bool = true).
| Parameter | Default | Notes |
|---|---|---|
location |
westeurope |
drives the private DNS zone name |
namePrefix |
<aks> |
≤ 12 chars, prefixes every resource |
systemNodeVmSize / systemNodeCount |
Standard_D8s_v3 / 3 |
8 vCPU / 32 GiB nodes, autoscales 3→6. (Pick a family with quota in your region — DSv5 was 0 in this subscription's westeurope, DSv3 had 100 vCPU.) |
availabilityZones |
["1","2","3"] |
zonal spread for HA |
vnetAddressSpace |
10.0.0.0/16 |
must not collide with peered nets |
deployVpnGateway |
true |
the P2S kubectl path |
vpnClientAddressPool |
172.16.201.0/24 |
must not overlap the VNet |
vpnClientRootCertData |
"" |
base64 root public cert (can add later) |
deployBackupStorage |
false |
self-managed pgBackRest blob; off because we use the managed private Flexible Server instead |
deployPortalIdentity |
true |
portal Workload Identity (UAMI + one federated credential per portalNamespaces entry) for the in-pod self-updater's ACR polling. Output portalIdentityClientId → selfUpdate.azureClientId. |
portalNamespaces |
(required — no default) | namespaces that run the portal; one federated credential each (subject system:serviceaccount:<ns>:memex-portal-sa). Set it in main.parameters.json. Deliberately has no default: a default of [] would create zero credentials and still report success, leaving every portal pod unable to reach ACR with nothing to read. |
grantSharedAcrPull |
false |
author the portal UAMI's AcrPull on the shared cross-RG ACR in-bicep (needs UAA on meshweaver-shared); default = grant out-of-band like the kubelet. A per-deployment ACR is granted in-bicep regardless. |
sharedAcrResourceGroup |
meshweaver-shared |
RG of the shared ACR; used only when grantSharedAcrPull=true. |
deployContentFileShares |
true |
Azure Files account + named shares for static PV binding (dynamic provisioning needs no shares) |
deployPostgresFlexible |
true |
PRIVATE (VNet-injected) PostgreSQL Flexible Server with pgvector + managed PITR |
postgresAdminPassword |
(required, @secure) |
pass at deploy time: --parameters postgresAdminPassword=... — never commit |
postgresSkuName |
Standard_D2ds_v5 |
2 vCPU / 8 GiB GeneralPurpose; bump for more DB headroom |
postgresHighAvailability |
true |
zone-redundant hot standby in a 2nd AZ |
A per-deployment ACR (infra/modules/acr.bicep) is granted in-bicep and needs
nothing here. A shared registry in another resource group cannot be granted
from a deployment scoped to this one, so two role assignments are made
out-of-band — and both are needed, for different readers:
# 1. The cluster KUBELET — this is what pulls the container images.
KUBELET=$(az aks show -g <rg> -n <cluster> \
--query identityProfile.kubeletidentity.objectId -o tsv)
az role assignment create --assignee-object-id $KUBELET --assignee-principal-type ServicePrincipal \
--role AcrPull --scope $(az acr show -n <acrName> --query id -o tsv)
# 2. The portal WORKLOAD IDENTITY — this is what the in-pod self-updater uses to
# LIST tags on the registry. Provisioned by infra/modules/portal-identity.bicep and
# federated to system:serviceaccount:<ns>:memex-portal-sa for every portalNamespaces entry.
PORTAL_MI=$(az identity show -g <rg> -n <portal-identity> --query principalId -o tsv)
az role assignment create --assignee-object-id $PORTAL_MI --assignee-principal-type ServicePrincipal \
--role AcrPull --scope $(az acr show -n <acrName> --query id -o tsv)
# Wire its clientId into selfUpdate.azureClientId for each env (the same value everywhere):
az deployment sub show --name memex-aks-infra \
--query properties.outputs.portalIdentityClientId.value -o tsvPure-IaC alternative to both grants: deploy with
grantSharedAcrPull=truefor the portal UAMI — it needs User Access Administrator on the shared registry's resource group. See DeploymentAKS → Portal self-update and SELF-UPDATE.md.
Take the server's fully-qualified name from the postgresFqdn output, not a
private IP:
MEMEX_PG_CONN='Host=<pg-server>.postgres.database.azure.com;Port=5432;Username=memexadmin;Password=<PW>;Database=memex;SslMode=Require;Trust Server Certificate=true'🚨 A private IP used to be recommended here to dodge the portal's Entra-token
branch. That was a workaround for a bug that no longer exists, and it is now
simply wrong — a VNet-injected Flexible Server resolves its FQDN to the private
IP through the linked private DNS zone anyway. The rule lives in code
(src/MeshWeaver.Hosting.PostgreSql/AzurePostgres.cs): the token path is taken
only when the host ends in .postgres.database.azure.com AND the string
carries no password. FQDN + password therefore takes the plain-Npgsql path, by
design and pinned by
test/MeshWeaver.Hosting.PostgreSql.Test/AzurePostgresAuthSelectionTests.cs
(AzureHostWithPassword_DoesNotUseManagedIdentity). Leave the password out and
you get managed-identity auth; that is the switch, not the host spelling.
The API server has a private IP only, so kubectl cannot reach it from outside
the VNet. az aks command invoke is the primary path and the only one any
automation here uses: it runs a command inside a transient pod on the cluster,
so no tunnel, no jumpbox, and no network line-of-sight are required.
az aks command invoke -g <rg> -n <cluster> --command "kubectl get pods -A"
# --file ships local files into that pod — this is how every script here runs:
az aks command invoke -g <rg> -n <cluster> --command "bash deploy.sh" --file .It needs the Azure Kubernetes Service Cluster User role plus
Microsoft.ContainerService/managedClusters/runcommand/action, and nothing on
the network side.
When to use which
| You want to… | Use |
|---|---|
| Apply manifests, run helm, read logs, patch a deployment | az aks command invoke — works from anywhere, and is what CI does |
Run the scripts in scripts/ |
az aks command invoke … --file (they are written for it) |
kubectl port-forward to Grafana, or psql straight at the private Postgres |
a route into the VNet: the optional P2S VPN, or a Bastion jumpbox |
A native local kubectl experience with tab-completion and long-running watches |
the optional P2S VPN |
Both are supported; the VPN is for interactive tooling that needs to open a
socket into the VNet, and nothing automated depends on it. check-chart-drift.sh
makes the split explicit — it takes --via kubectl|aks-invoke, and its only CI
caller (.github/workflows/chart-drift.yml) passes aks-invoke.
Jumpbox + Azure Bastion is the third option: the
AzureBastionSubnetis already carved out bynetwork.bicep; deploy Bastion + a small VM in the VNet and run kubectl from there. Not implemented in this sample.
The chart references ghcr.io/systemorph/memex-portal-ai and
ghcr.io/systemorph/memex-migration. Both are multi-arch (linux/amd64 +
linux/arm64): the release path builds the base with
docker buildx --platform linux/amd64,linux/arm64 --push and the app images with
-p:RuntimeIdentifiers="linux-x64;linux-arm64" -p:ContainerRuntimeIdentifiers="linux-x64;linux-arm64"
(no -r linux-x64), so one tag is an OCI image index that serves both architectures.
🚨 values.aks.yaml carries an image: block (registry / portal /
migration / tag / pullPolicy) that NOTHING READS. No template under
../helm/templates/** references .Values.image — grep -rn '\.Values\.image' deploy/helm/ returns nothing. Setting it changes exactly zero bytes of rendered
YAML, which is why scripts/deploy.sh still repoints the running Deployments
with kubectl set image after helm upgrade. The keys the chart does consume:
| Key | Consumed by | Chart default (../helm/values.yaml) |
|---|---|---|
portal.image |
../helm/templates/memex-portal/deployment.yaml |
ghcr.io/systemorph/memex-portal-ai:latest |
migration.image |
../helm/templates/memex-migration/job.yaml |
ghcr.io/systemorph/memex-migration:latest |
portalNext.image |
../helm/templates/memex-portal/portal-next.yaml |
(only when portalNext.enabled) |
Each is a full reference including the tag — registry, repository and tag in one string, not a registry prefix that gets composed with something else.
kubectl set image: a later helm upgrade
re-renders the Deployment from portal.image and silently reverts anything
kubectl set image had pointed it at.
Three options:
Option A — pull from GHCR directly (simplest; needs node egress to ghcr.io,
which the default outboundType: loadBalancer provides). The chart defaults
already point there — nothing to set.
Option B — import into the private ACR (recommended for a locked-down cluster; AcrPull is already granted to the kubelet identity):
ACR=<acrName> # from outputs (without .azurecr.io)
az acr import --name $ACR --source ghcr.io/systemorph/memex-portal-ai:latest --image memex-portal-ai:latest
az acr import --name $ACR --source ghcr.io/systemorph/memex-migration:latest --image memex-migration:latest
# optional lean / base variants:
az acr import --name $ACR --source ghcr.io/systemorph/memex-portal:latest --image memex-portal:latest
az acr import --name $ACR --source ghcr.io/systemorph/memex-portal-ai-base:latest --image memex-portal-ai-base:latest
az acr importpreserves the manifest list — it copies the whole multi-arch OCI image index (all per-platform child manifests) referenced by the source tag, so the ACR copy stays multi-arch (amd64 + arm64); it does not flatten to one architecture. (The retired tag lane used the same server-side copy to mirror released images from GHCR into ACR;release.ymlnow promotes inside ACR by digest.)
Then point the real keys at the ACR — on the helm command line, or (better,
so it survives every upgrade) in your values overlay:
helm upgrade --install memex ../helm -f ../helm/values.yaml -f values.aks.yaml -n $NS \
--set portal.image=<acrName>.azurecr.io/memex-portal-ai:latest \
--set migration.image=<acrName>.azurecr.io/memex-migration:latestOption C — build the images yourself (what the release path does; needs
docker + the .NET 10 SDK). Images are multi-arch so one tag serves x86
cloud nodes AND Apple-silicon local k3s, and every install can pull-and-self-update
natively. kubectl/helm commands elsewhere in this file, these
build paths are repository-root-relative — run them from the repo root, not
from deploy/aks:
# Base image (node + Claude Code + Copilot CLIs) — MULTI-ARCH manifest list.
# @anthropic-ai/claude-code is pure JS (arch-independent); @github/copilot resolves its
# per-platform binary via npm optional deps, so each arch's build bakes in the matching CLI.
az acr build --registry <acrName> --image memex-portal-ai-base:latest \
--platform linux/amd64 --platform linux/arm64 deploy/base-images/portal-ai
# (equivalent local build: `docker buildx build --platform linux/amd64,linux/arm64 \
# -t <acrName>.azurecr.io/memex-portal-ai-base:latest --push deploy/base-images/portal-ai`)
# App images — drop `-r linux-x64`; set RuntimeIdentifiers + ContainerRuntimeIdentifiers to both
# RIDs. With no single RID, the SDK (>= 8.0.405; we're on .NET 10) publishes per-RID and combines
# them into an OCI Image Index (manifest list). ContainerRuntimeIdentifiers MUST be a subset of
# RuntimeIdentifiers (set them equal). The arm64 leg layers on the multi-arch base above.
az acr login --name <acrName>
dotnet publish ../MeshWeaver.Plugins/src/Memex.Portal.Distributed/Memex.Portal.Distributed.csproj \
-c Release --no-self-contained -t:PublishContainer -p:PublishProfile= \
-p:RuntimeIdentifiers="linux-x64;linux-arm64" -p:ContainerRuntimeIdentifiers="linux-x64;linux-arm64" \
-p:ContainerRegistry=<acrName>.azurecr.io -p:ContainerRepository=memex-portal-ai \
-p:ContainerImageTag=latest -p:ContainerBaseImage=<acrName>.azurecr.io/memex-portal-ai-base:latest
dotnet publish MeshWeaver.Plugins/src/Memex.Database.Migration/Memex.Database.Migration.csproj \
-c Release --no-self-contained -t:PublishContainer -p:PublishProfile= \
-p:RuntimeIdentifiers="linux-x64;linux-arm64" -p:ContainerRuntimeIdentifiers="linux-x64;linux-arm64" \
-p:ContainerRegistry=<acrName>.azurecr.io -p:ContainerRepository=memex-migration -p:ContainerImageTag=latestFirst multi-arch roll — order matters: the multi-arch base (
memex-portal-ai-base:latest) must exist before the first multi-arch app build, or the arm64 leg has no base layer. Rebuild the base multi-arch once (theaz acr build … --platform …above), then the app builds — and the continuous self-update path — work for both architectures.
Everything below runs inside the cluster via az aks command invoke (Step 2).
scripts/deploy.sh is the packaged form of exactly these steps — see
"Or run scripts/deploy.sh" at the end of this step.
NS=memex
kubectl create namespace $NS
# 0) Custom StorageClass for the non-root portal (uid 1654) — cluster-scoped,
# so no namespace. Must exist before the RWX PVCs that reference it.
kubectl apply -f manifests/storageclass-azurefile.yaml
# 1) Real RWX + DB PVCs (must exist before the workloads mount them)
kubectl apply -n $NS -f manifests/portal-pvcs.yaml
# 2) Install the chart with the AKS overlay (set a real PG password!)
helm install memex ../helm \
-f ../helm/values.yaml \
-f values.aks.yaml \
--namespace $NS \
--set secrets.memex_postgres.memex_postgres_password='<strong-password>' \
--set secrets.memex_migration.memex_postgres_password='<strong-password>' \
--set secrets.memex_portal.memex_postgres_password='<strong-password>'
# 3) Ingress with cookie session affinity (enable a controller first)
az aks approuting enable -g <rg> -n <cluster> # managed nginx
kubectl apply -n $NS -f manifests/portal-ingress.yamlOlder revisions of this document had a step 3 that ran
kubectl patch deployment memex-portal-deployment -n $NS \
--type strategic --patch-file manifests/portal-ha-patch.yaml # ← DO NOT
kubectl patch statefulset memex-postgres-statefulset -n $NS \
--type strategic --patch-file manifests/postgres-pvc-patch.yaml # ← DO NOTBoth are now the chart's job, and re-applying them is not merely redundant:
- The RWX volumes are rendered by the chart.
persistence:invalues.aks.yamlsupplies aclaimNameper drive and../helm/templates/memex-portal/deployment.yamlmounts each one directly, so a plainhelm upgradegets/data,/mnt/content,/mnt/usersright. That replaced the bolt-on patch precisely because any unpatched apply silently reverted/datatoemptyDir, wiping the assembly / NuGet / DataProtection caches on every restart in the gap (values.aks.yaml, thepersistence:comment; the same warning headsscripts/deploy.sh). replicasbelongs to the autoscaler.portal-ha-patch.yamlwritesspec.replicas: 2. KEDA is enabled in this overlay (keda.enabled: true,minReplicas: 2), and the chart therefore omitsspec.replicasentirely under KEDA so the HPA is the single writer. Patching it back in means helm and the HPA fight over one field, and everyhelm upgradeyanks a scaled-out deployment back down until the HPA pushes it out again. This is invariant #1 ofscripts/check-chart-invariants.sh— "spec.replicas is ABSENT when a ScaledObject exists" — a gate that exists because the chart described exactly this contradiction in git for a month and produced a production 503 on 2026-08-14. Writing the patch re-creates the bug the gate was built to catch.- Postgres has its own
volumeClaimTemplate.postgres-pvc-patch.yamlwas written when the chart usedemptyDirfor the data dir; the StatefulSet now declaresvolumeClaimTemplates: memex-pgdata(RWO, 10Gi). And on the default path here the in-cluster Postgres is not used at all — see the Database section.
What the patch file still holds that the chart does not is the zone-spread
topologySpreadConstraints. If you want that, put it in your values overlay or a
chart change — do not hand-patch the Deployment.
Secrets: never commit a real password. The CSI Secrets Store add-on is enabled in
aks.bicep— wiresecrets.memex_*to Key Vault via aSecretProviderClassfor production rather than--set.
Blazor sticky sessions: the ingress affinity cookie is mandatory. Without it, SignalR circuit reconnects can land on the wrong replica and users see "Attempting to reconnect…" loops. The annotations are in
manifests/portal-ingress.yaml(nginx today, AGIC commented).
The same sequence, packaged for the private cluster. Run it from a staging
directory holding the script, aks-extras.yaml (StorageClass + PVCs), your
values.deploy.yaml secrets (from values.deploy.example.yaml — keep it OUT of
git), a copy of the chart as ./helm, and a copy of values.aks.yaml:
az aks approuting enable -g <rg> -n <cluster> # managed nginx (public LB)
cd deploy/aks/scripts
export MEMEX_PG_CONN='Host=<pg-server>.postgres.database.azure.com;Port=5432;Username=memexadmin;Password=<PW>;Database=memex;SslMode=Require;Trust Server Certificate=true'
az aks command invoke -g <rg> -n <cluster> \
--command "MEMEX_PG_CONN='$MEMEX_PG_CONN' bash deploy.sh" --file .It creates the namespace, applies the StorageClass + RWX PVCs, runs
helm upgrade --install with the chart + values.aks.yaml + values.deploy.yaml,
scales the chart's in-cluster Postgres to 0 (this deployment uses the Flexible
Server), repoints both images at the shared ACR, and patches the
connection-string secret — a chart-gen gap: the generated secret template
hardcodes the in-cluster Postgres host. It does not patch volumes any more,
for the reason above.
Observability is folded in: export GRAFANA_PW=… alongside MEMEX_PG_CONN
and deploy.sh also brings up Grafana + Loki + Prometheus (see Observability);
omit it to skip monitoring. At the model level, AddMemex's OtlpEndpoint
option wires OTEL_EXPORTER_OTLP_ENDPOINT for OTLP traces/metrics — not needed
for log shipping, since Promtail scrapes stdout.
Point DNS at the ingress controller's public IP, then let cert-manager issue the certificate over HTTP-01:
IP=$(az aks command invoke -g <rg> -n <cluster> \
--command "kubectl get svc -n app-routing-system nginx -o jsonpath='{.status.loadBalancer.ingress[0].ip}'")
az network dns record-set a add-record -g <dns-rg> -z <your-zone> -n <record> --ipv4-address $IP --ttl 300
cd deploy/aks/scripts
az aks command invoke -g <rg> -n <cluster> --command "bash tls.sh" --file tls.sh # cert-manager + Let's Encrypt + ingressHTTP→HTTPS redirect is automatic once the ingress has TLS. Verify while bypassing the DNS cache:
curl -sS -o /dev/null -w "%{http_code} verify=%{ssl_verify_result}\n" \
--resolve <host>:443:$IP https://<host>/Without it, any client that connects without SNI gets the self-signed "Kubernetes Ingress Controller Fake Certificate" — and corporate TLS-inspection / URL-categorization appliances probe exactly that way, then flag the whole domain as insecure and block it for their users (seen 2026-08: a client's IT blocked a portal host in Firefox and Edge over this).
Point the app-routing controller's default cert at the flagship host's
cert-manager secret — patch the NginxIngressController CR, not the nginx
deployment (the addon operator reverts direct deployment edits):
az aks command invoke -g <rg> -n <cluster> --command \
"kubectl patch nginxingresscontroller default --type merge -p '{\"spec\":{\"defaultSSLCertificate\":{\"secret\":{\"name\":\"<tls-secret>\",\"namespace\":\"<ns>\"}}}}'"
# verify from outside — must show the real cert, not "Acme Co":
echo | openssl s_client -connect <host>:443 -noservername 2>/dev/null | openssl x509 -noout -subjectThe setting survives addon updates but is NOT re-created on a cluster
rebuild — re-apply it whenever the cluster (or the NginxIngressController CR)
is recreated.
The portal's persistent data is split across dedicated Azure Files (RWX) drives, one per concern, each mounted at an explicit path. This keeps user content off the small/churny framework-cache volume and lets you size, expand, and (optionally) back up each drive independently.
| Drive (PVC) | Mount path | Holds | Repointed by |
|---|---|---|---|
memex-data |
/data |
Framework caches only: DataProtection keys (/data/dataprotection-keys), NodeType assembly-cache, NuGet package-cache |
Deployment__DataRoot=/data |
memex-content |
/mnt/content |
The content collection — uploaded files / media / per-node-hub content ({BasePath}/content/{nodePath}) |
Storage__BasePath=/mnt/content |
memex-attachments |
/mnt/attachments |
Attachments drive (see note) | (forward-looking — no env knob today) |
memex-users |
/mnt/users |
Co-hosted CLI configs | (unchanged) |
memex-pgdata |
Postgres data dir | Database files (RWO, managed-csi) | — |
The portal image runs as the .NET app user — uid 1654 / gid 1654 (the
non-root uid baked into the chiseled dotnet/aspnet images). The default
azurefile-csi class mounts shares uid=0,gid=0 (root-owned, mode 0777). That
usually works, but a non-root process on a root-owned share is brittle — it's
exactly the failure mode that produced
UnauthorizedAccessException: Access to the path '/data/dataprotection-keys' is denied
on the Docker-Compose deploy. manifests/storageclass-azurefile.yaml pins
uid=1654,gid=1654 so every inode on the share is owned by the portal user,
plus mfsymlinks (DataProtection writes symlinks), cache=strict, actimeo=30,
and nobrl (Azure Files SMB rejects the POSIX byte-range locks that SQLite and
other file-lock-y libraries take; nobrl makes them no-ops). reclaimPolicy: Retain keeps the share + its keys/content if a PVC is accidentally deleted.
- Content —
MemexConfiguration.ConfigureMemexMeshreadsStorage:BasePathas the FileSystem content-collection root and gives each node hub acontent/{nodePath}subdirectory under it. The overlay setsStorage__BasePath=/mnt/content. This is the real functional change. - Attachments — the portal also maps an
attachmentscollection (MapContentCollection("attachments", "storage", "attachments/{nodePath}")).⚠️ In the Distributed / Filesystem backend (the image this sample runs) thestoragesource collection is not separately registered — only the Monolith registers it — so attachments has no independently env-repointable base path today (there is noStorage__Attachments__BasePathsetting). We mount/mnt/attachmentsanyway so the drive exists and is ready: if the app later registers a filesystemstoragesource rooted there, no manifest change is needed. (If you run the Monolith image instead, it does register thestoragesource from theStoragesection, so attachments followsStorage__BasePath— but then content + attachments share one drive.)
manifests/portal-pvcs.yaml requests the drives against the azurefile-memex
StorageClass; the CSI driver creates a share per PVC automatically. Nothing
else to do — this is what Step 4 applies.
Growing a share later is NOT a kubectl patch. Capacity is volumes[].size on
the instance's Hosting/Deployment record; the hosting operator's
hosting-pv-resize (operator/bin/) grows the claim to it as a step of the
Provision / Reconcile action — grow-only, read back from the claim's status,
refusing a class without allowVolumeExpansion. Azure Files expands online.
If you'd rather pre-create named shares in one account (to size/quota/firewall/
back them up centrally), set deployContentFileShares: true (default) so
infra/modules/files.bicep provisions a StorageV2 / Standard_ZRS / largeFileSharesState=Enabled account with shares content, attachments,
data, users, otel-logs. Then bind a static PV per share. Grab the
account name + key:
SA=$(az deployment sub show --name memex-aks-infra \
--query "properties.outputs.contentFilesAccount.value" -o tsv)
RG=$(az deployment sub show --name memex-aks-infra \
--query "properties.outputs.resourceGroupName.value" -o tsv)
KEY=$(az storage account keys list -g $RG -n $SA --query "[0].value" -o tsv)
kubectl create secret generic azure-files-creds -n memex \
--from-literal=azurestorageaccountname=$SA \
--from-literal=azurestorageaccountkey=$KEYThen a PV/PVC pair per share (content shown; repeat for attachments/data/users):
apiVersion: v1
kind: PersistentVolume
metadata:
name: memex-content-pv
spec:
capacity: { storage: 128Gi }
accessModes: [ReadWriteMany]
storageClassName: "" # static — no dynamic provisioner
persistentVolumeReclaimPolicy: Retain
mountOptions: [dir_mode=0777, file_mode=0777, uid=1654, gid=1654, mfsymlinks, cache=strict, actimeo=30, nobrl]
csi:
driver: file.csi.azure.com
volumeHandle: memex-content # any cluster-unique id
volumeAttributes:
resourceGroup: <RG>
storageAccount: <SA>
shareName: content # the pre-created share from files.bicep
nodeStageSecretRef:
name: azure-files-creds
namespace: memex
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: memex-content, namespace: memex }
spec:
accessModes: [ReadWriteMany]
storageClassName: ""
volumeName: memex-content-pv
resources: { requests: { storage: 128Gi } }Keyless alternative: instead of the account-key secret, federate a Workload Identity with the portal's ServiceAccount and grant it Storage File Data SMB Share Contributor — same pattern as the pgBackRest identity in
storage.bicep.
Keep dynamic (Option A) as the default; reach for static binding only when you need named, centrally-managed shares.
A single OpenTelemetry Collector DaemonSet captures telemetry for the whole cluster and archives it to a mounted Azure Files share — no per-GB managed-telemetry ingest cost. (A self-hosted in-cluster file archive is the cost-conscious default for a self-hosted deployment, avoiding the per-GB ingest charges of a managed PaaS observability service.)
every node: Azure Files (RWX)
┌─ pod stdout/stderr ─┐ filelog (hostPath /var/log/pods) /mnt/otel-logs
│ (ALL namespaces) ├────────────────┐ ├ logs-<node>.json
└─────────────────────┘ ▼ ├ traces-<node>.json
┌─ otel-collector (DaemonSet) ─┐ └ metrics-<node>.json
memex-portal x3 ─ OTLP ────►│ k8sattributes + resourcedetect│──► file exporter (rotated)
(:4317 grpc via Service) │ + batch │──► debug exporter (kubectl logs)
└───────────────────────────────┘
- Sources:
filelogtails/var/log/pods/**/*.logon every node (so all pod logs cluster-wide are captured, not just the portal), andotlp(gRPC :4317 / HTTP :4318) receives the portal's traces/logs/metrics. - Enrichment:
k8sattributes(pod/namespace/node/deployment) +resourcedetection+batch. - Sink: the
fileexporter writes rotated JSON to/mnt/otel-logs(max_megabytes: 100,max_backups: 10). Each DaemonSet pod namespaces its output by node name (logs-<node>.json) via the downward-APINODE_NAMEenv, so replicas don't clobber each other on the shared share. Adebugexporter (verbositybasic) mirrors a summary to the collector's own stdout forkubectl logs ds/otel-collector.
Memex.Portal.ServiceDefaults/ServiceDefaults.cs →
AddOpenTelemetryExporters() does:
var useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlp) builder.Services.AddOpenTelemetry().UseOtlpExporter();So setting OTEL_EXPORTER_OTLP_ENDPOINT turns on the OTLP exporter — the only
telemetry path (Azure Application Insights has been discontinued; observability is
the Prometheus / Grafana / Loki stack). values.aks.yaml sets
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 and
OTEL_EXPORTER_OTLP_PROTOCOL=grpc, so the portal exports metrics/traces to the
in-cluster collector. Application logs reach Loki out-of-band via Promtail
scraping pod stdout (no app wiring).
Loki makes errors findable; this makes them reported. mw-log-watcher is a small Deployment in
monitoring that reads fail:/crit: lines from Loki, groups them by fault, and POSTs each
distinct fingerprint to the portal — which triages it with an agent and opens one GitHub issue in
the repository that owns the code. Ten thousand identical errors are one ticket; recurrences comment
on it and reopen it if it was closed.
Loki ──query_range from a persisted cursor──▶ mw-log-watcher ──POST /api/log-incidents──▶ portal
(ns monitoring, own PVC) │
Admin/_LogIncident/{fingerprint} ◀┘
agent triage ─▶ GitHub App ─▶ issue
Three properties worth knowing before you operate it:
- It is not in the portal's namespace, deliberately. The component that notices the portal is throwing errors must survive the portal being the thing that is broken. When the portal is wedged the watcher keeps reading Loki and queues to its PVC; delivery is delayed, nothing is lost.
- Its state directory must be a persistent volume. The cursor and the undelivered queue live
there. On an
emptyDira pod restart replays the lookback window and re-reports. - It is off until configured, in two independent ways. No
LogWatch:IngestTokenon the portal ⇒ the ingest endpoint is not mapped at all (reaching it spends model budget and opens issues, so absence means off, never open). No repository routed ⇒ the control plane idles rather than running agent rounds that could never end in a ticket.
Design and configuration reference: LogWatchTriage.md.
The ingest endpoint is not even mapped without a token, so a half-configured watcher is inert rather than half-working.
# 1. ONE shared secret, both sides. The watcher presents it; the portal requires it.
TOKEN=$(openssl rand -hex 32)
az aks command invoke -g <rg> -n <cluster> --command \
"kubectl -n monitoring create secret generic mw-log-watcher --from-literal=ingest-token=$TOKEN"
# …then set LogWatch__IngestToken to the SAME value on the portal (KeyVault → its secret store).
# 2. Where tickets go. Without at least one route the control plane idles by design —
# it will not spend agent rounds on incidents it could never file.
# LogWatch__DefaultRepository = <owner>/<repo>
# LogWatch__Routes__0__Prefix = MeshWeaver.
# LogWatch__Routes__0__Repository = <owner>/<repo>
# LogWatch__Routes__1__Prefix = Memex.
# LogWatch__Routes__1__Repository = <owner>/<other-repo>
# Every key carries the LogWatch__ prefix — a bare Routes__0__Repository does not bind, and an
# unbound route reads exactly like "no route configured": incidents pile up at New, silently.
# Issues are opened as the GitHub App (GitHub:App), never a user's OAuth token.
# 3. The watcher image is PUBLISHED, never hand-built. Its source is MeshWeaver.Plugins
# (src/MeshWeaver.LogWatcher — it left core on 2026-08-27, #2276), and that repo's
# `log-watcher image` lane pushes <acrName>.azurecr.io/memex-log-watcher:<version>, :<sha7>
# and :latest on every change to the watcher or its contract (Plugins#823/#992). Read the
# published tags before step 4 and pick one; a tag you did not see in the registry is not a
# tag you can deploy:
az acr repository show-tags -n <acrName> --repository memex-log-watcher -o tsv
# 4. Apply the Deployment + PVC (edit the image tag + watched namespaces in the manifest first).
az aks command invoke -g <rg> -n <cluster> \
--command "kubectl apply -f log-watcher.yaml" \
--file manifests/observability/log-watcher.yamlVerify — and know what each failure looks like:
az aks command invoke -g <rg> -n <cluster> --command \
"kubectl -n monitoring logs deploy/mw-log-watcher --tail=50"| What the log says | Meaning |
|---|---|
Loki: N red line(s) … then Reported <fp> … — 200 |
Working end to end. |
Log watcher is not configured |
Deploy step 1 missed — PortalUrl/IngestToken unset. |
Portal REJECTED report … with 401 |
The two tokens differ — the watcher's secret and the portal's LogWatch__IngestToken must be the same string. |
Portal REJECTED report … with 404 |
The portal's LogWatch__IngestToken is unset, so /api/log-incidents is not mapped at all — deploy step 1 was done on the watcher side only. |
Loki: 0 line(s) forever |
The namespace label is wrong, or Promtail is not shipping that namespace. |
Could not persist watcher state … Access to the path … is denied |
The state PVC mounted root-owned. The pod needs securityContext.fsGroup: 1654 (the shipped manifest sets it). The watcher still reports, but the cursor stops persisting, so a restart replays the lookback window. |
Incidents appear but stay New |
Deploy step 2 missed — no repository routed, so the control plane idles. |
Then browse Admin/_LogIncident in the portal: every incident links to the ticket
it opened and to the triage thread that wrote it.
🚨 The state directory must be a real volume. On an emptyDir a pod restart
replays the lookback window and re-reports. The manifest ships a PVC for this reason.
To turn it off: kubectl -n monitoring scale deploy/mw-log-watcher --replicas=0.
Clearing the portal's LogWatch__IngestToken also closes the ingest endpoint.
Neither deletes existing incidents.
A provisioned Grafana alert rule covers the human-facing half — it shows red-log volume in Grafana but deliberately does not open tickets. Ticketing hangs off the watcher's cursor instead, because an alert notification that fires while its receiver is down is simply lost: acceptable for a nudge, not for "every distinct error gets a ticket".
The rule and the Grafana dashboards used to live here as
deploy/aks/dashboards/. They are bound to one Grafana's datasource UIDs and to specific namespaces — elsewhere they render empty panels rather than failing — so they moved to the deployment repo alongside the environments they describe.scripts/import-dashboards.shis the generic importer and stays here; point it at whatever dashboard JSON you have.
NS=memex
kubectl apply -f manifests/storageclass-azurefile.yaml # if not already applied
kubectl apply -n $NS -f manifests/observability/otel-pvc.yaml
kubectl apply -n $NS -f manifests/observability/otel-collector-config.yaml
kubectl apply -n $NS -f manifests/observability/otel-collector.yaml
# portal already points at the collector via values.aks.yaml — restart to pick up env if needed
kubectl rollout restart deployment memex-portal-deployment -n $NSThe file archive above is the durable, cheap sink. For searching logs there is a
grafana/loki-stack install (Loki + Promtail + Grafana + Prometheus, datasources
auto-wired, Promtail shipping every pod's stdout into Loki) in the monitoring
namespace:
export GRAFANA_PW='<strong-password>'
cd deploy/aks/scripts
az aks command invoke -g <rg> -n <cluster> \
--command "GRAFANA_PW=$GRAFANA_PW bash install-observability.sh" --file install-observability.shEverything except the portal stays private, so Grafana has no public endpoint. Reach it over a route into the VNet — the optional P2S VPN or a Bastion jumpbox — and port-forward:
az aks get-credentials -g <rg> -n <cluster> # VPN connected
kubectl -n monitoring port-forward svc/loki-grafana 3000:80 # http://localhost:3000 (admin / $GRAFANA_PW)In Grafana → Explore → Loki, the portal logs are {namespace="memex"} (add
|= "error" or |~ "signin-microsoft" to narrow).
The archive lives on the otel-logs Azure Files share. Inspect from a pod:
kubectl exec -n memex ds/otel-collector -- ls -lh /mnt/otel-logs
kubectl exec -n memex ds/otel-collector -- tail -n 50 /mnt/otel-logs/logs-<node>.json…or download straight from the Files share with the account key (Option B account, or the dynamically-created share — find it under the cluster's node resource group):
az storage file download-batch \
--account-name <filesAccount> --account-key <key> \
--source otel-logs --destination ./otel-archive-
Rotation is per-node-file: 100 MB × 10 backups ⇒ ~1 GB/node retained, then oldest rolls off. Bump
max_backups/ theotel-logsPVC size for longer retention; add an Azure Files lifecycle/cleanup CronJob for time-based pruning. -
Cost: a flat Azure Files share (≈€0.06/GB-month Standard) vs. per-GB managed-telemetry ingest — the archive is the cheap default for self-hosting.
-
Azure Table storage has NO native OTel Collector exporter — Azure Files (the
fileexporter over a mounted SMB share) is the chosen sink. For richer query/alerting at scale, swap thefileexporter for either:- Grafana Loki backed by Azure Blob (the
lokiexporter → Loki → Blob object store) for label-indexed log search, or - the
azuremonitorexporter to ship into Azure Monitor / Log Analytics (KQL, alerts) — accepting the per-GB ingest cost.
Both are drop-in exporter swaps in
otel-collector-config.yaml; neither is implemented here to keep the default zero-PaaS and cheap. - Grafana Loki backed by Azure Blob (the
This sample defaults to a managed, private database:
infra/modules/postgres.bicep provisions an Azure Database for PostgreSQL
Flexible Server injected into the delegated postgres subnet — no public
endpoint. It resolves only inside the VNet (and over the P2S VPN) via the
*.private.postgres.database.azure.com private DNS zone that network.bicep
links. This matches the private-everything posture: private API server, private
drives (Azure Files), private DB.
- pgvector is allowlisted (
azure.extensions = VECTOR,UUID-OSSP) for the portal's embeddings + HNSW vector search; thememexdatabase is created. - Managed PITR — automatic backups + WAL; restore to any second in the
retention window with
az postgres flexible-server restore. No in-cluster backup machinery, sodeployBackupStorage: falseand thepostgres-pvc-patch/pgbackrestmanifests are not applied. - HA —
postgresHighAvailability: trueruns a zone-redundant hot standby.
Point the portal at the server's private FQDN (from the postgresFqdn output)
in values.aks.yaml — set MEMEX_HOST and the connection-string secret to the
Flexible Server endpoint, user memexadmin, db memex. The portal's
Postgres path auto-detects Azure-managed-identity vs basic auth from the
connection string (see Memex.Portal.Distributed/Program.cs); basic auth with
the admin password works out of the box.
In-cluster alternative: set
deployPostgresFlexible: false(+ revertdeployBackupStorage: true) to use the self-managed Postgres StatefulSet + pgBackRest PITR instead — see Step 5 below. The two are mutually exclusive; pick one.
The built-in MeshWeaver platform documentation ships inside the images (embedded
resources) and is served from memory at runtime. So that it also shows up in the portal's
main search bar, the one-shot migration mirrors every doc page into a Postgres doc
schema on each deploy:
- Full-text search (always on, no external dependency). Each doc's title + one-line description is indexed; the search bar finds docs by keyword out of the box.
- Semantic / vector search (opt-in). When an embeddings endpoint is configured, the
migration also computes an embedding per doc (title + description + body) and stores it in
the pgvector HNSW index, so natural-language queries (“how do I cancel a running job”)
rank the right page. The portal embeds the search query the same way, so both sides must use
the same model. (
pgvectoris already allowlisted on the Flexible Server — see the Database section.)
The mirror is a full replace + incremental embed: every deploy upserts the current doc set
and prunes rows whose source page no longer ships, and the (paid) embedding call only fires for
pages whose content actually changed since the last run. Reads/navigation still come from the
in-memory copy — the doc schema is purely a search index.
The embeddings provider is optional. Leave it unset and docs are full-text-searchable with
no external AI dependency; set it to enable vector ranking. The deploy AppHost
(deploy/aspire/Memex.Deploy.AppHost) reads three parameters and flows them to both the
migration and the portal:
| Deploy parameter | Container env (migration and portal) | Notes |
|---|---|---|
Parameters:embedding-endpoint |
Embedding__Endpoint |
Azure AI Foundry embeddings endpoint (Cohere embed-v4). Empty ⇒ full-text only. |
Parameters:embedding-key |
Embedding__ApiKey |
Secret — only emitted when set (ACA/compose reject empty secrets). |
Parameters:embedding-model |
Embedding__Model |
Defaults to embed-v-4-0 (the Cohere embed-v4 Azure AI Foundry deployment name). Migration + portal must agree (sizes the vector column). |
Set them via dotnet user-secrets / env / GitHub secrets at publish time, e.g.:
aspire publish --apphost deploy/aspire/Memex.Deploy.AppHost/Memex.Deploy.AppHost.csproj \
-o deploy/helm -- --mode kubernetes \
-- --Parameters:embedding-endpoint=https://<foundry>.services.ai.azure.com/... \
--Parameters:embedding-key=<key>For the AKS / Helm path these surface as config.Embedding__* (and the key as a
secrets.* entry) on the regenerated chart's migration Job and portal Deployment — set them in
values.aks.yaml (or --set) alongside the other secrets, and wire the key through the CSI
Secrets Store add-on for production rather than committing it.
HA runs the portal as multiple silos, which must form one cluster via a shared membership
store. This deployment uses Postgres-backed ADO.NET clustering on the same Postgres server
in a separate orleans database (so silo membership never shares tables or locks with mesh
data). It works for a single silo too, so the self-host AppHosts use it in every mode — Localhost
clustering is never used in a deployment.
How it's wired (all DB config flows through Aspire):
- The
AddMemexintegration declares theorleansdatabase on the same Postgres server and references it on both the portal and the migration, so Aspire injectsConnectionStrings:orleans. - The portal silo selects the provider from the feature flag
Features:Orleans:Clustering(set toAdoNetby the self-host AppHosts; legacyDeployment:Orleans:Clusteringstill works) and callsUseAdoNetClustering(Invariant=Npgsql)against that injected connection string. - The db-migration creates the Orleans membership tables (
OrleansQuery,OrleansMembershipTable, …) in theorleansdatabase from the verbatim Orleans 10 PostgreSQL scripts — idempotent, and it auto-creates the database on self-managed Postgres. The Orleans provider does not self-create these tables, so the migration must run before the silos start (the portal alreadyWaitForCompletion(migration)).
Aspire's Orleans integration only wires Redis / Azure-Table clustering — not ADO.NET — so the
orleansdatabase lives in Aspire while the silo wiring and the membership DDL live in the portal and the migration. (The Azure/ACA path instead uses Azure Table Storage clustering via the Aspire Orleans integration and doesn't need any of this.)
AKS / Flexible Server note: on the managed-Postgres path, ensure the orleans database exists
on the server (the chart's migration Job creates the tables but the managed server must allow the
DB; azure.extensions already includes pgvector for the mesh DB). The regenerated chart carries
Features__Orleans__Clustering=AdoNet and the orleans connection string from the Aspire model;
set the connection-string secret in values.aks.yaml alongside the memex one. HA needs ≥2
replicas (the portal-ha-patch.yaml already sets 3).
values.aks.yaml wires the login providers the portal's auth pipeline
(AuthenticationBuilderExtensions) reads from Authentication:*:
| Provider | Config keys (env: Authentication__<P>__*) |
Redirect URI to register |
|---|---|---|
| Microsoft / Entra (HOME) | TenantId (Systemorph tenant GUID), ClientId, ClientSecret |
https://portal.example.com/signin-microsoft |
ClientId, ClientSecret |
https://portal.example.com/signin-google |
|
ClientId, ClientSecret |
https://portal.example.com/signin-linkedin |
- Setting
Authentication__Microsoft__TenantIdto a real tenant GUID (notcommon) makes that AAD the home directory.values.aks.yamlships theCHANGE_ME_aad_tenant_idplaceholder — fill it fromaz account show --query tenantId -o tsv. - Any provider with a
ClientIdset is offered on the login page; the presence of external providers flips the portal into multi-provider mode and dev login is off in the Distributed image. - You still must create the app registrations / OAuth clients and fill the
CHANGE_ME_*ClientIds (config) +ClientSecrets (secrets) — those are real credentials this repo does not contain. Register each redirect URI above. - Host is
portal.example.com(ingress + TLS) — Step 4b points DNS at the ingress controller's IP and issues the certificate.
Microsoft / Entra (single-tenant home directory):
az ad app create --display-name "Memex Portal (portal.example.com)" \
--sign-in-audience AzureADMyOrg \
--web-redirect-uris "https://portal.example.com/signin-microsoft"
az ad app credential reset --id <appId> --display-name aks --years 1 # => client secretGoogle (Cloud Console) and LinkedIn (Developer portal): create web OAuth
clients with redirect URIs https://portal.example.com/signin-google and
https://portal.example.com/signin-linkedin.
Recommended for turnkey prod: use Azure Database for PostgreSQL Flexible Server instead (managed PITR, automatic WAL, restore to any point in the retention window with one CLI call, no in-cluster moving parts). See the Flexible Server section. pgBackRest is the all-in-cluster, self-managed option for when you want the database to live next to the workload.
NS=memex
# Workload-Identity SA — put the pgBackRestIdentityClientId output in the SA
kubectl apply -n $NS -f manifests/pgbackrest/serviceaccount.yaml # edit the client-id first
kubectl apply -n $NS -f manifests/pgbackrest/configmap.yaml
# Add the WAL-archive wiring + sidecar to the Postgres StatefulSet
kubectl patch statefulset memex-postgres-statefulset -n $NS \
--type strategic --patch-file manifests/pgbackrest/sidecar-patch.yaml
# Wait for the DB pod to roll, then create the stanza + scheduled backups
kubectl apply -n $NS -f manifests/pgbackrest/cronjobs.yamlBefore applying, substitute your storage account + container into the manifests
(they carry __AZURE_ACCOUNT__ / pgbackrest placeholders): the
backupStorageAccount and backupContainerName infra outputs, and the
pgBackRestIdentityClientId into the service account annotation.
How it works:
- WAL archiving: the init container appends
archive_command = pgbackrest … archive-pushtopostgresql.conf, so every completed WAL segment is pushed to Blob continuously. This is what makes PITR (replay to an arbitrary timestamp) possible. - Scheduled backups:
pgbackrest-full(weekly) +pgbackrest-diff(daily) CronJobs write full/differential base backups to the same Blob repo. - Keyless auth: the SA is federated (Workload Identity) with the managed
identity that holds Storage Blob Data Contributor — no account key on disk.
(To use a key instead, set
repo1-azure-keyin the ConfigMap and drop the workload-identity annotation.)
NS=memex; POD=memex-postgres-statefulset-0
# Ad-hoc full backup (zero contention — runs in the sidecar):
kubectl exec -n $NS $POD -c pgbackrest -- \
pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=memex --type=full backup
# Verify repo health + list backups:
kubectl exec -n $NS $POD -c pgbackrest -- \
pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=memex check
kubectl exec -n $NS $POD -c pgbackrest -- \
pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=memex infoRestore is destructive to the live data dir — it replaces cluster files, so the database must be stopped during the restore.
NS=memex
# 1) Scale the portal down (no writers) and stop Postgres.
kubectl scale deployment memex-portal-deployment -n $NS --replicas=0
kubectl scale statefulset memex-postgres-statefulset -n $NS --replicas=0
# 2) Run pgBackRest restore against the PVC from a one-off pod that mounts
# memex-pgdata + the pgbackrest config. PITR to a timestamp:
kubectl run pgbackrest-restore -n $NS --rm -i --restart=Never \
--image=docker.io/woblerr/pgbackrest:2.54.2 \
--overrides='{
"spec": {
"serviceAccountName": "pgbackrest-sa",
"containers": [{
"name": "restore",
"image": "docker.io/woblerr/pgbackrest:2.54.2",
"command": ["pgbackrest","--config=/etc/pgbackrest/pgbackrest.conf","--stanza=memex",
"--type=time","--target=2026-05-30 14:30:00+00","--delta","restore"],
"volumeMounts": [
{"name":"memex-pgdata","mountPath":"/var/lib/postgresql/data"},
{"name":"pgbackrest-conf","mountPath":"/etc/pgbackrest"}
]
}],
"volumes": [
{"name":"memex-pgdata","persistentVolumeClaim":{"claimName":"memex-pgdata"}},
{"name":"pgbackrest-conf","configMap":{"name":"pgbackrest-config","items":[{"key":"pgbackrest.conf","path":"pgbackrest.conf"}]}}
],
"metadata": {"labels": {"azure.workload.identity/use": "true"}}
}
}'
# 3) Bring Postgres back; it replays WAL up to the target time, then promotes.
kubectl scale statefulset memex-postgres-statefulset -n $NS --replicas=1
kubectl logs -n $NS memex-postgres-statefulset-0 -f # watch recovery complete
kubectl scale deployment memex-portal-deployment -n $NS --replicas=3Replace the
--targettimestamp (and remember the ConfigMap's account/container placeholders must be rendered). For "latest possible" recovery drop--type=time --target=…and pgBackRest replays all archived WAL.
For most production deployments, prefer the managed database:
- Built-in PITR — automatic backups + WAL; restore to any second in the
retention window (7–35 days) via
az postgres flexible-server restore. - No StatefulSet, no PVC, no pgBackRest sidecar/CronJobs to operate.
- pgvector is supported (
azure.extensions).
To switch: set deployBackupStorage: false, do not apply the
postgres-pvc-patch / pgbackrest manifests, scale the chart's Postgres
StatefulSet to 0, provision a Flexible Server (private-access / VNet-injected
into a delegated subnet), and point the portal's MEMEX_HOST /
MEMEX_JDBCCONNECTIONSTRING / connection-string secret at it in
values.aks.yaml. The portal is unchanged — it just talks to a different
Postgres endpoint.
# a) The manifest MUST carry every arch leg. A partial manifest list = ImagePullBackOff on the
# missing arch, which presents as "the deploy hung" (memex-cloud V46 outage, 2026-07-19).
az acr manifest show -r <acrName> -n memex-portal-ai:<tag> \
| jq -r '.manifests[]?.platform | "\(.os)/\(.architecture)"' # expect linux/amd64 AND linux/arm64
# b) Roll.
az aks command invoke -g <rg> -n <cluster> --command \
"kubectl set image deploy/memex-portal-deployment memex-portal=<acrName>.azurecr.io/memex-portal-ai:<tag> -n <env> \
&& kubectl rollout status deploy/memex-portal-deployment -n <env> --timeout=600s"
# c) Verify with REAL signals, looped. HTTP 200 is the Blazor shell and proves nothing.
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " https://<host>/api/content/<space>/content/<file>; doneFresh pods invalidate every dynamic NodeType's cached assembly (the framework MVID
changed), so they all need a recompile. Left lazy, that compile happens on user
requests after the pod is already serving — and a type nothing happens to touch
stays "no definition" until its pages hang with "No response received …
SubscribeRequest → target X" (SocialMedia/Post on memex-cloud, 2026-07-30:
every post page burned the 60 s timeout all morning while the portal looked
healthy). Managed envs therefore run the bake ON — these three knobs travel
together (values.aks.yaml carries them; keep them in every env overlay so a
helm upgrade never reverts them):
| Knob | What it does |
|---|---|
config.memex_portal.PreWarm__DynamicTypes: "true" |
every new pod sweeps + compiles ALL dynamic NodeTypes at start (resumes from the shared /data cache — warm restarts are cheap) |
config.memex_portal.PreWarm__GateReadiness: "true" |
/health stays red until the sweep is green; with maxSurge 1 / maxUnavailable 0 a regressed type STALLS the rollout with the old image serving. values.aks.yaml currently sets BOTH to "false" — read the namespace, not this table (#1981). The two keys are ONE setting: the gate reads state only the sweep writes, so gate-without-sweep is disarmed at startup (it used to register and stay permanently green) and logged at Critical. Turning the gate on means turning PreWarm__DynamicTypes on in the same change. It was tried 2026-08-02 and reverted the same day on 7 FALSE regressions — all cross-silo SubscribeRequest timeouts, not compile errors (#694 residue). The gate no longer reads "no answer" as "it broke": a TimedOut outcome is filed as unevaluated and can never gate, and that leniency now survives the cascade (a dependent of an unevaluated upstream is UpstreamUnevaluated, also non-gating). Only a CompileError — or an UpstreamFailed cascading from one — on a previously-healthy type stalls a roll |
config.memex_portal.PreWarm__AllowUnprovenBake: "false" |
✅ OFF (strict). The gate also refuses readiness when the sweep errored — enumeration threw or timed out — because such a pod verified nothing and a gate that certifies "I verified nothing" is worse than no gate. That guard used to live in the pre-run bake Job ("FINDING NOTHING IS NOT PASSING", exit 3, Bake__AllowEmpty) and was lost when #1357 retired the Job; it is now enforced on the surviving path as BakePhase.Faulted. "true" only to roll forward past an environment that cannot answer the enumeration, accepting lazy compilation. It can never waive a real regression, and /health keeps reporting the bake as unproven |
probes.startup: {periodSeconds: 10, failureThreshold: 180} (= 30 min) |
progressDeadlineSeconds is DERIVED from these two in the chart, so raising them can't leave it behind. Was 1080 (3 h) until 2026-08-10, sized from a "~90 s/type" estimate that was 37x too high — a window that long detected nothing |
🚨 Before you trust the gate, verify the namespace actually reads it. The gate protects a portal through exactly two deployment facts, and on 2026-08-10 two of the three portals had drifted away from both. A gated config in a namespace missing either is false confidence — it reports and the outage happens anyway.
| Must be true | Why | Check |
|---|---|---|
a startupProbe exists on /health |
that probe is the ONLY reader of the gate; no startupProbe (or one on /alive / /ready / /healthz) ignores it entirely |
kubectl -n <ns> get deploy memex-portal-deployment -o jsonpath='{.spec.template.spec.containers[0].startupProbe}' |
strategy.maxUnavailable: 0 |
surge-first keeps the old pod serving until the new one passes; at maxUnavailable:1 with replicas:1 the only serving pod can be deleted BEFORE the replacement is ready |
kubectl -n <ns> get deploy memex-portal-deployment -o jsonpath='{.spec.strategy.rollingUpdate}' |
Both are rendered correctly by the chart — a helm upgrade restores them. A
per-env portal-patch.json or a hand kubectl patch can still override them,
which is how the drift happened.
🪦 There is no pre-run bake Job any more (#1347) — the CI bake replaced it
(#1660 WS3). The separate memex-bake image was removed after two weeks of
running in zero namespaces. On its only AKS run (memex-cloud, 2026-07-30)
memex-bake:3.0.0-ci.1565 computed a different framework fingerprint than the
running portal-ai:3.0.0-ci.1565 — same version, same commit, separately
published — so its framework-stale kickoff started flipping CURRENT NodeType
records to Pending and rebuilding them for a framework nothing serves. The cause
was the per-build +build.<UtcNow.Ticks> stamp InformationalVersion then carried
under CIRun: every dotnet publish minted a fresh MeshWeaver.Graph MVID.
#1660 WS3 removed that stamp and made CI builds deterministic — the framework
identity is now the API-surface hash (s<hash>, FrameworkBuildIdentity;
identical across CI builds AND across internal-only merges, moved only by breaking
surface changes or Graph changes) — so the Build-and-Test run's bake artifact IS
adoptable, and usually already published: main-cd's publish-bake job copies it
onto the portals' /data share (prebuilt-bundles/<identity>/<source>/), and each
booting pod seeds its own identity's bundles (PreWarm__PrebuiltBundleRoot) before
its sweep.
The pod-side sweep remains the enforcement. It runs in the serving process,
adopts whatever CI published, and compiles the remainder — 76 s for 280 types cold
(memex-cloud, batch direct-compile), seconds when the CI bake covered the shipped
content. The "fail before prod" contract is given by PreWarm__GateReadiness +
maxSurge:1 / maxUnavailable:0: the new pod refuses readiness until its OWN bake
is green while the old image keeps serving.
Verify after every roll — the bake completing is the deploy signal, not HTTP 200:
# The sweep's verdict (compiled=N alreadyBaked=M compileErrors=0 …) — read the NEWEST pod
# explicitly: `kubectl logs deploy/…` picks an arbitrary pod and mid-rollout that is often the OLD one.
NEW=$(kubectl -n <env> get pods -l app.kubernetes.io/component=memex-portal \
--sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}')
kubectl -n <env> logs "$NEW" | grep "DynamicTypePreWarmer: warm-up complete"
# The gate's verdict (Healthy "baked in …" / Unhealthy "regressed" — rollout stalled on purpose):
kubectl -n <env> get pods # new pod 0/1 while baking is CORRECT; investigate only a Regressed log lineLive-env equivalent without a helm apply (what enabled memex-cloud on 2026-07-30):
kubectl -n <env> patch configmap memex-portal-config --type merge \
-p '{"data":{"PreWarm__DynamicTypes":"true","PreWarm__GateReadiness":"false"}}'
kubectl -n <env> patch deployment memex-portal-deployment --type json -p \
'[{"op":"replace","path":"/spec/template/spec/containers/0/startupProbe/periodSeconds","value":10},
{"op":"replace","path":"/spec/template/spec/containers/0/startupProbe/failureThreshold","value":180}]'
# the probe patch rolls the deployment; the new pod bakes behind the gate while the old serves- A pod sitting 0/1 for a long time is the gate doing its job while a cold bake
runs; only a
REFUSING READINESS … Regressions:log line means a type broke on this image — fix the type, never widen the timeout. - Retries are cheap — the sweep resumes from the shared cache. Every type a
sweep DID build is on
/datafor good; deleting a gate-red pod re-runs only what is missing. Progress across attempts is monotonic. ⚠️ Known flake while core #694 is open (cross-silo reply routing): during a roll the mesh briefly runs TWO silos (old serving + new baking), and in that window the pod-side sweep's shared-source resolution can fail nondeterministically — the symptom is aCompileErrorfull of unresolved names that live in a SIBLING type'sSource/(shared=@…), on a type that compiles clean when triggered individually. First verify with a targeted compile (MCPcompile @<type>— recycle the type's hub first if the subscribe times out; the targeted subscribe rides the SAME cross-silo routing, so it completes reliably only in a single-silo window — delete the baking pod and use the ~2 minutes before its replacement joins), then let the replacement's re-sweep find the fixed typesalreadyBaked. (Observed live on the first gated roll, memex-cloud 2026-07-30: the gate caught 2 types with stale-green records whose newest assemblies were 6 days old — real, invisible breakage — plus sweep failures on shared-source types that compiled clean when triggered individually in a single-silo window.)
Mechanism: NodeTypeCompilation.md.
kubectl scale --replicas=0 (the documented heal for a wedged mesh) is silently
reverted by a ScaledObject with minReplicaCount: 2 — you conclude the heal
failed when it never ran. kubectl get scaledobject -n <env> first, and check
PAUSED.
autoscaling.keda.sh/paused-replicas: "<n>" pins the deployment at n, deletes
the HPA, and makes minReplicaCount inert. Nothing in any chart sets it,
kubectl get deploy does not show it, and it survives every roll — so it reads as
"this deployment is just single-replica" long after whoever applied it has moved on.
Nobody applies it for fun. It is the most direct way to say "stop making new
silos", so assume it is suppressing a multi-silo defect until you have evidence
otherwise. memex-cloud carried one for 16 days (2026-07-29 → 2026-08-14): it
was applied 87 minutes after the second fix for #694 — cross-silo posts lose
AccessContext: static content 500s on ~50% of requests with 2 replicas — and 29
seconds after KEDA scaled the namespace back up. Removing it without reading that
history is re-running the incident.
Establish provenance before unpausing — the same way you would for any hand-applied field:
kubectl -n <env> get scaledobject <name> -o jsonpath='{range .metadata.managedFields[*]}{.manager} {.operation} {.time}{"\n"}{end}'
kubectl -n <env> get scaledobject <name> -o jsonpath='{.status.lastActiveTime}{"\n"}' # when it last scaledmanagedFields gives you the writer (kubectl-annotate) and the timestamp;
lastActiveTime immediately before it is the tell that someone watched it scale and
stopped it. Then find what shipped or broke that day (git log --since=<date> --until=<date+1>, closed issues), and only unpause once you can name the defect and
point at its fix. Treat the unpause as a monitored experiment with a rollback
trigger, not a config tidy-up — a fix merged while the namespace was pinned has only
ever been exercised in the transient two-silo window of a rollout, never in steady
state. scripts/check-chart-drift.sh reports the annotation as CLUSTER-ONLY drift so
it stops being invisible.
DOTNET_DbgEnableMiniDump + DOTNET_DbgMiniDumpName=/data/dumps/… do nothing
without a volume at that path — createdump does not create directories, so the
crash destroys its own evidence. The chart mounts a dedicated memex-dumps
emptyDir; verify the LIVE pod actually has it:
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 — every crash will produce nothing"portal-patch.json replaces volumes by index, so adding or
reordering a chart volume silently misaligns an environment and a cluster can run an
older volume set while the chart in git looks right. Diff live mounts against the
chart after every deploy.
Symptom: some requests hang or serve garbled state while most succeed, typically
after a burst of content syncs or a scheduled bake. Cause class (2026-08-25,
MeshWeaver#2194): each NodeType publication mints a new AssemblyLoadContext and
serving instances stay on the OLD build behind the Recycle banner, so every
superseded ALC stays rooted — the type-hosting silos climb to tens of GB and go
GC-bound while still answering /ready (the readiness probe's path since MeshWeaver#3330), so
Kubernetes never pulls them from rotation — deliberately: eviction hands the traffic to siblings
converging on the same ceiling. A progress-aware /alive restarts such a pod instead.
az aks command invoke -g <rg> -n <cluster> --command "kubectl top pods -n <ns> --no-headers"One or two pods far above their siblings in BOTH memory and CPU = this. Remedy:
kubectl delete pod the outliers (grace-drain; the Deployment replaces them).
Prevention: enable Modules:AutoRecycleOnStaleBuild (MeshWeaver#2192) so instances
converge onto each newly published build and old ALCs collect — mechanism in
NodeTypeCompilation.md.
The repo already models the deployment in
deploy/aspire/Memex.Deploy.AppHost. Running
aspire publish (or azd) against that model is what produced the generic
../helm chart and the ../aca Container Apps Bicep.
This AKS sample is complementary, not generated: the Aspire publishers emit a portable Helm chart and an ACA topology, but they do not emit a private-AKS + P2S-VPN + pgBackRest-PITR stack. So the relationship is:
- Aspire owns the app model → it generates
../helm(Deployments, Service, StatefulSet, migration Job, config/secret templates). Keep regenerating that from Aspire when the app composition changes. - This sample owns the AKS platform →
infra/*.bicep(private cluster, VPN, ACR, backup storage) +values.aks.yamloverlay +manifests/for the pieces the generic chart doesn't template. These are hand-authored Azure platform concerns that don't belong in the app model.
If you want the Aspire AppHost to drive the infra too, you can call this
Bicep from a Memex.Deploy.AppHost publisher: add it as an
AddBicepTemplate("aks-infra", "infra/main.bicep") resource (or invoke
az deployment sub create from a publish hook) and pass the cluster/ACR/storage
outputs into the Helm release step. That keeps a single aspire/azd-driven
entry point while this directory remains the source of truth for the AKS-specific
Azure resources. The chart stays Aspire-generated; only the platform Bicep and
the overlay are added here.
To keep the chart in sync after an app-model change, regenerate ../helm from
Aspire and re-run Step 4 — values.aks.yaml and manifests/ continue to apply
on top unchanged (they only reference stable resource names like
memex-portal-deployment / memex-postgres-statefulset).
Aspire's Kubernetes publisher generates the workload chart, but it does not
provision an AKS cluster, a private Postgres Flexible Server, a VPN, or
Let's Encrypt. Those platform pieces are the Bicep + the steps above. The AppHost
(../aspire/Memex.Deploy.AppHost) owns the app
model and the deploy parameters — including the OAuth providers; this document
stitches the platform around it. All config flows from deploy parameters → env.
- Dump mount not applied to the live clusters (2026-07-28): all three
namespaces carry the dump env vars while no pod mounts
/data/dumps, so every productionexit=139so far produced no dump. Applying the current chart fixes it; until then amkdir /data/dumpson the/dataPVC is a stopgap that puts heap-sized dumps on a shared 16Gi share instead of the size-bounded emptyDir. Multi-replica HA(chart side done 2026-08-14): Orleans AdoNet clustering is the HA provider (Features:Orleans:Clustering, legacy keyDeployment:Orleans:Clustering), backed by a dedicatedorleansdatabase the migration'sOrleansClusteringSetupcreates. The chart now templates the replica count and omitsspec.replicasentirely under KEDA so the HPA owns it;scripts/check-chart-invariants.shasserts the prerequisites at render time. What remains is per-environment and per-cluster, not chart work: each env's overlay must setkeda.enabled, and a pausedScaledObjectstill pins the count regardless (see "Scaling: KEDA wins" above — checkPAUSEDbefore believing a replica number).Chart connection string(fixed 2026-08-14):../helm/templates/memex-portal/secrets.yamland its migration counterpart now takesecrets.memex_{portal,migration}.ConnectionStrings__memex/__orleansfrom values, falling back to the in-cluster host only when unset. An env that supplies them no longer needs the post-helmsecret patch inscripts/deploy.sh. 🚨 Until an env's values file DOES supply them,helm upgradestill re-renders the in-cluster host — and for__orleansnothing patches it back, so an AdoNet namespace loses cluster membership. The chartfails the render if an externalConnectionStrings__memexis supplied without a matching__orleans, so the half-configured case cannot ship silently.- Secrets → Key Vault: move the PG password, master key, and OAuth secrets into
Key Vault via the CSI Secrets Store add-on (enabled in
infra/modules/aks.bicep).
helm uninstall memex -n memex
kubectl delete namespace memex # also deletes the PVCs (Azure Files/Disk)
az group delete --name <rg> --yes --no-wait # cluster, VPN, ACR, storage, VNetDeleting the namespace deletes the PVCs and their backing Azure Files shares / managed disks. The pgBackRest Blob repo lives in the separate backup storage account and survives the cluster — delete the resource group (or just the storage account) to remove backups, mindful of the 30-day soft-delete.
- Secrets: move
memex_postgres_passwordout of--set/values into Key Vault via the CSI Secrets Store add-on (already enabled). - API server: private-only;
enablePrivateClusterPublicFQDN=false.infra/modules/aks.bicepsetsdisableLocalAccounts: falsewith the comment "keep admin kubeconfig usable over the VPN" — that is the only thing local accounts are for here, sinceaz aks command invokedoes not need them. If you skip the VPN (deployVpnGateway: false), consider Entra-only (disableLocalAccounts=true+ AKS-managed Entra RBAC) for prod. - VPN auth: the optional P2S uses certificate auth for simplicity. Entra ID authentication on the P2S is stronger (revocation, conditional access).
- ACR:
publicNetworkAccessis Enabled for first-runaz acr import. For a fully private cluster, switch ACR to Premium + Private Endpoint and disable public access once images are imported. - Egress: default
outboundType: loadBalancerallows node egress to GHCR / Azure. For a locked-down network useuserDefinedRouting+ Azure Firewall and Option B (ACR import) so no pull traffic leaves the VNet.
You do not need this to deploy. Step 2's az aks command invoke covers every
apply / helm / logs / patch operation, and it is the only path anything automated
here uses. Provision the gateway when you want a route into the VNet for
interactive tooling that opens its own socket:
kubectl port-forwardto in-cluster Grafana (it has no public endpoint);psqlstraight at the private Postgres Flexible Server;- a native local
kubectl— tab-completion, long-runningwatch/logs -f— without a standing jumpbox VM.
The gateway is still provisioned by default (infra/main.bicep,
param deployVpnGateway bool = true, module vpn.bicep) and it costs 20–45 minutes
of deploy time; deployVpnGateway: false is supported and documented on that
parameter. Because the cluster is private, the tunnel is what lets kubectl resolve
the API server: the linked private DNS zone privatelink.<region>.azmk8s.io maps
the API server FQDN to its private IP once your laptop is attached to the VNet.
# Root CA
openssl genrsa -out p2sRoot.key 2048
openssl req -x509 -new -nodes -key p2sRoot.key -subj "/CN=Memex-P2S-Root" -days 3650 -out p2sRoot.crt
# Client cert signed by the root
openssl genrsa -out p2sClient.key 2048
openssl req -new -key p2sClient.key -subj "/CN=Memex-P2S-Client" -out p2sClient.csr
openssl x509 -req -in p2sClient.csr -CA p2sRoot.crt -CAkey p2sRoot.key -CAcreateserial -days 365 -out p2sClient.crt
# Base64 of the ROOT public cert (single line, no PEM headers) — feed to Bicep
openssl x509 -in p2sRoot.crt -outform der | base64 -w0 ; echoYou can either:
- paste that base64 string into
vpnClientRootCertDataand redeploy infra, or - upload it after the fact without redeploying:
az network vnet-gateway root-cert create \
--resource-group <rg> --gateway-name <namePrefix>-vpngw \
--name P2SRootCert --public-cert-data "<base64-root-cert>"--public-cert-data is read as a FILE PATH by current az versions — pass
the path, NOT the inline base64 string and NOT @file. On Windows the equivalent
certificate mint is:
$root = New-SelfSignedCertificate -Type Custom -KeySpec Signature -Subject "CN=MemexP2SRootCert" -KeyUsage CertSign -KeyExportPolicy Exportable -CertStoreLocation Cert:\CurrentUser\My -HashAlgorithm sha256 -KeyLength 2048
$client = New-SelfSignedCertificate -Type Custom -DnsName MemexP2SChild -KeySpec Signature -Subject "CN=MemexP2SChildCert" -Signer $root -KeyExportPolicy Exportable -CertStoreLocation Cert:\CurrentUser\My -HashAlgorithm sha256 -KeyLength 2048 -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.2")
[IO.File]::WriteAllText("root.txt",[Convert]::ToBase64String($root.RawData))
# then: az network vnet-gateway root-cert create -g <rg> --gateway-name <vpn-gateway> \
# --name MemexP2SRootCert --public-cert-data root.txtaz network vnet-gateway vpn-client generate \
--resource-group <rg> --name <namePrefix>-vpngw --output tsv # -> download URL (zip)Download the returned zip, install the profile (the Azure VPN Client or
OpenVPN; the bundle ships an OpenVPN .ovpn you augment with p2sClient.crt +
p2sClient.key), and connect.
az aks get-credentials --resource-group <rg> --name <clusterName>
kubectl get nodes # resolves the PRIVATE API server over the tunnel
kubectl -n monitoring port-forward svc/loki-grafana 3000:80 # what the tunnel is actually forIf kubectl times out: confirm the VPN is connected, that the private DNS zone
privatelink.<region>.azmk8s.io is linked to the VNet (it is, via network.bicep),
and that your client gets a 172.16.201.x address.
Carried across from
DEPLOY-RUNBOOK.mdwhen #2144 consolidated the deployment docs. The runbook was deleted in that consolidation; this analysis was written against it and would otherwise have been lost to a modify/delete conflict.
volume silently misaligns an environment and a cluster can run an older volume set while the chart in git looks right. Diff live mounts against the chart after every deploy.
Symptom: some requests hang or serve garbled state while most succeed, typically after a burst of
content syncs or a scheduled bake. Cause class (2026-08-25, MeshWeaver#2194): each NodeType
publication mints a new AssemblyLoadContext and serving instances stay on the OLD build behind the
Recycle banner, so every superseded ALC stays rooted — the type-hosting silos climb to tens of GB
and go GC-bound while still answering /ready (the readiness probe's path since MeshWeaver#3330),
so Kubernetes never pulls them from rotation — deliberately, because eviction hands the traffic to
siblings converging on the same ceiling. A progress-aware /alive restarts such a pod instead.
az aks command invoke -g <rg> -n <cluster> --command "kubectl top pods -n <ns> --no-headers"One or two pods far above their siblings in BOTH memory and CPU = this. Remedy: kubectl delete pod the outliers (grace-drain; the Deployment replaces them). Prevention: enable
Modules:AutoRecycleOnStaleBuild (MeshWeaver#2192) so instances converge onto each newly
published build and old ALCs collect — mechanism in
src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md.
You no longer have to be watching. PortalReplicaWorkingSetDiverged
(deploy/aks/scripts/values.observability.yaml, group portal-degraded) fires when one
memex-portal container's working set is >3× the lightest in the same namespace AND above 8 GB for
15 minutes — the exact 2026-08-25 shape, where nothing alerted and the two sick pods served hung
pages for three and a half hours. It is DETECTION only; the fix is still convergence. It needs the
observability stack installed (install-observability.sh), and it can never fire on a
single-replica namespace by construction (max == min).
✅ Closed on #2194: a progress-aware /alive ships in Memex.Portal.Distributed
(ProcessProgressHealthCheck, MeshWeaver.Plugins#1234), and readiness has been moved OFF that path
onto /ready so the progress verdict restarts a pod instead of evicting it 60 s earlier onto
siblings at the same ceiling (MeshWeaver#3330). Do NOT "fix" anything here by pointing readiness
back at /health OR at /alive — the first re-creates the 2026-07-21 probe death-spiral, the
second rebuilt it out of the containment. See Doc/Architecture/ProbeSemantics.