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.

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 ../MeshWeaver.Plugins/src/Memex.AppHost/Memex.AppHost.csproj -- --mode <prod|test> (the command Aspire docs sanction).
  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: loops dotnet script tools/check-db-version.csx -- <mode> <pg-fqdn> 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:

Read the constants; don't trust a number quoted here. The gate compares against DbVersionGate.ExpectedDbVersion and the script against the ExpectedVersion constant in tools/check-db-version.csx. A completed migration writes MigrationRunner.LatestVersion — the highest Version in MigrationRegistry.All.

⚠️ These three are currently drifted (ExpectedDbVersion = 32, check-db-version.csx = 26, highest registered migration V51), contrary to the "bump in lock-step with the highest Vxx_*.cs" instruction in both constants' comments. Both gates are minimums, so they pass — but a database stranded anywhere in V27–V51 clears both and neither the deploy gate nor the startup gate will notice. Verify the actual constants before relying on either as a migration check.

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 — the Postgres FQDN is required, as a second argument or via PG_HOST (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)
dotnet script tools/check-db-version.csx -- prod "$PG_HOST"

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.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.