Deploying to Azure Container Apps

This is one of two deploy routes. Use it for the .NET Aspire test / prod modes, which provision and run on Azure Container Apps (Sweden Central) — the AppHost (../MeshWeaver.Plugins/src/Memex.AppHost) is the single source of truth for every resource (PostgreSQL, Blob Storage, Orleans clustering, Application Insights). For the shared AKS-cluster portal (memex namespace), see DeploymentAKS.md. These are different routes to different targets — choose by where you're deploying.

🚨 The wrapper lives in MeshWeaver.Plugins, beside the AppHost it deploys — tools/deploy.sh and tools/check-db-version.csx at the root of a MeshWeaver.Plugins checkout, run from that root. It used to live in this repository and still named memex/aspire/Memex.AppHost, which left with the rest of memex/, so it could not run from a core checkout (#7404). Every tools/… path on this page is relative to the MeshWeaver.Plugins root.

Deployment Modes

The AppHost's four primary modes, selected via --mode <mode> (default local):

Mode PostgreSQL Blob Storage Orleans Portal name
local Docker pgvector container Azurite emulator Emulated (in-process) memex-local
test Azure (memex-test) Azure — provisioned by Aspire Azure memex-test
prod Azure (memex) Azure — provisioned by Aspire Azure memex-prod
monolith FileSystem (standalone) — — memex-monolith

Two further modes exist for running the AppHost locally against deployed Azure resources: local-test and local-prod. They alone attach to the existing storage accounts meshweavermemextest / meshweavermemex via RunAsExisting + Azure Identity (az login, no secrets), and they need ConnectionStrings:memex set to the Azure PostgreSQL so provisioning is bypassed. Deployed test/prod do not use those accounts — Aspire provisions storage for them.

Prerequisites

  1. Azure CLI authenticated — az login
  2. Aspire CLI installed — dotnet tool install -g Aspire.Cli
  3. Docker running (builds container images)
  4. Secrets configured in the AppHost project (see Deployment.md → Secrets Management)
  5. dotnet-script installed for the post-deploy DB version check — dotnet tool install -g dotnet-script
  6. AZURE_USER_PRINCIPAL_NAME exported — your AAD UPN (e.g. you@example.com). tools/deploy.sh exits 64 immediately without it, and check-db-version.csx throws: the DB check connects to Postgres as your AAD identity, and the UPN is the Postgres username. The signed-in user must be a Postgres AAD admin (or in a group that is).

🚨 Always use tools/deploy.sh — never bare aspire deploy

tools/deploy.sh — Three-step safe deploy tools/deploy.sh prod | test Step 1 aspire deploy (AppHost + mode) Step 2 Discover Postgres FQDN via az CLI Step 3 Poll check-db-version every 15s, 10 min cap FQDN not resolved? Fail (exit 2) Deadline hit? Fail + dump 100 log lines Portal-side safeguards (runtime) DbVersionGate IHostedService at startup One-shot: db_version ≥ ExpectedDbVersion Stops app if below → revision Failed DbVersionHealthCheck Live healthcheck Wraps same db_version query Catches post-deploy manual drift

The deploy wrapper closes the silent-failure gap in aspire deploy with two poller steps, backed by two runtime safeguards inside the portal.

tools/deploy.sh prod    # or: tools/deploy.sh test

Running aspire deploy on its own silently passes when the db-migration container crashes. Aspire's pipeline reports ✓ provision-db-migration-containerapp completed successfully as soon as the Container App definition provisions — it does not watch the migration container's actual exit code. The result is a half-migrated database, an exit-0 deploy, and a portal that comes up against broken data with 401 errors for every user.

The wrapper script closes that gap in three steps:

  1. Runs aspire deploy --project src/Memex.AppHost/Memex.AppHost.csproj -- --mode <prod|test> (the command Aspire docs sanction), and refuses with exit 64 when it is not run from a MeshWeaver.Plugins root.
  2. Discovers the deployed Postgres FQDN — az postgres flexible-server list -g <rg> --query "[0].fullyQualifiedDomainName" — because the server name carries a random suffix that changes whenever the resource group is reprovisioned.
  3. Polls the database, not the container: reads the expected version from DbVersion.Latest (src/Memex.Database.Migration/DbVersion.cs) and loops dotnet script tools/check-db-version.csx -- <mode> <pg-fqdn> <expected> every 15 s against a 10-minute deadline. First success exits 0; on deadline it fails the deploy and dumps az containerapp logs show -n db-migration --tail 100.

It deliberately does NOT poll the container's exit code. db-migration is deployed as a regular Container App, not a Container Apps Job, and Container Apps treats any exit — including exit 0 — as a crash and restarts it. The replica never reaches Terminated, lastTerminationState.exitCode flickers between null and 0 across restarts, and a successful migration is indistinguishable from a crash loop. db_version in the database is the only authoritative completion signal, and polling it is an end-to-end check rather than a proxy for one.

Two additional safeguards run inside the portal itself:

One constant, three readers. DbVersionGate.ExpectedDbVersion, the deploy wrapper's expected version and MigrationRegistry.VerifyComplete() all read DbVersion.Latest (src/Memex.Database.Migration/DbVersion.cs in MeshWeaver.Plugins). The wrapper used to carry its own hand-kept constant, which sat at 26 while the migrations had reached 60 — a minimum that low passes a database dozens of migrations behind. It now reads DbVersion.Latest and passes it to the csx, which refuses to run without it.

Why not gate this inside aspire deploy itself? At the time the wrapper was written, Aspire exposed no first-party API for a deploy-time callback that can poll a provisioned resource and fail the pipeline (nor PublishAsAzureContainerJob, which would remove the crash-loop ambiguity above); the note in tools/deploy.sh attributes both to a later "Wave 14". The repo is now on Aspire 13.4.6 (Directory.Packages.props) and no DeployingCallbackAnnotation appears anywhere in the tree, so the bash poller is still the mechanism. Re-check the Aspire release notes before assuming this can collapse into an AppHost annotation.

Verifying a Deployment

tools/deploy.sh already runs the version gate automatically. If you ran aspire deploy directly, verify manually from the MeshWeaver.Plugins root — the Postgres FQDN is required, as a second argument or via PG_HOST, and the expected version as a third (the script throws without it; it is not discoverable from the mode alone because the server name carries a reprovision-random suffix):

export AZURE_USER_PRINCIPAL_NAME=you@example.com
PG_HOST=$(az postgres flexible-server list -g prod-memex \
  --query "[0].fullyQualifiedDomainName" -o tsv)
EXPECTED=$(sed -nE 's/^[[:space:]]*public const int Latest = ([0-9]+);.*/\1/p' \
  src/Memex.Database.Migration/DbVersion.cs)
dotnet script tools/check-db-version.csx -- prod "$PG_HOST" "$EXPECTED"

Success prints ✅ db_version=<n> (>= <ExpectedVersion>) and exits 0. After verification, open the portal URL, check the Aspire dashboard for service health, and review Application Insights for startup telemetry.

Container Apps infrastructure

Deployed modes (test, prod) run on Azure Container Apps in Sweden Central with sticky sessions enabled for Blazor Server.