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
- Azure CLI authenticated —
az login - Aspire CLI installed —
dotnet tool install -g Aspire.Cli - Docker running (builds container images)
- Secrets configured in the AppHost project (see Deployment.md → Secrets Management)
- dotnet-script installed for the post-deploy DB version check —
dotnet tool install -g dotnet-script AZURE_USER_PRINCIPAL_NAMEexported — your AAD UPN (e.g.you@example.com).tools/deploy.shexits 64 immediately without it, andcheck-db-version.csxthrows: 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
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:
- Runs
aspire deploy --project ../MeshWeaver.Plugins/src/Memex.AppHost/Memex.AppHost.csproj -- --mode <prod|test>(the command Aspire docs sanction). - 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. - 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 dumpsaz containerapp logs show -n db-migration --tail 100.
It deliberately does NOT poll the container's exit code.
db-migrationis deployed as a regular Container App, not a Container Apps Job, and Container Apps treats any exit — includingexit 0— as a crash and restarts it. The replica never reachesTerminated,lastTerminationState.exitCodeflickers betweennulland0across restarts, and a successful migration is indistinguishable from a crash loop.db_versionin 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:
DbVersionGate(Memex.Portal.Distributed/DbVersionGate.cs) — anIHostedServicethat queriesadmin.mesh_nodes.db_versiononce at portal startup and callsIHostApplicationLifetime.StopApplication()if the version is missing or belowExpectedDbVersion. It does not wait or retry. Container Apps then marks the revisionFailedand routes no traffic to it.DbVersionHealthCheck— a live healthcheck wrapping the same query, surfacing any drift if someone manually runs a partial migration viapsqlafter startup.
Read the constants; don't trust a number quoted here. The gate compares against
DbVersionGate.ExpectedDbVersionand the script against theExpectedVersionconstant intools/check-db-version.csx. A completed migration writesMigrationRunner.LatestVersion— the highestVersioninMigrationRegistry.All.⚠️ These three are currently drifted (
ExpectedDbVersion = 32,check-db-version.csx = 26, highest registered migrationV51), contrary to the "bump in lock-step with the highestVxx_*.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 deployitself? 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 (norPublishAsAzureContainerJob, which would remove the crash-loop ambiguity above); the note intools/deploy.shattributes both to a later "Wave 14". The repo is now on Aspire 13.4.6 (Directory.Packages.props) and noDeployingCallbackAnnotationappears 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.
- PostgreSQL — Azure PostgreSQL Flexible Server with pgvector, provisioned by Aspire (local:
pgvector/pgvector:pg17Docker container). - Azure Blob Storage — content files (attachments, documents); local uses the Azurite emulator.
- Orleans — Azure Table Storage for clustering + Blob Storage for grain state (local: emulated in-process).
- Application Insights — telemetry + distributed tracing, provisioned in all deployed modes.