Deployment Overview¶
The Cargonerds solution can be run locally with .NET Aspire and deployed to Azure through two parallel, independent paths — both driven from this single repository and the same common.props <Version>. The Aspire AppHost (Cargonerds.AppHost) is the declarative source of truth for what runs; the two Azure paths differ in where and how it runs.
For the bigger picture see the architecture overview, the .NET Aspire integration page and the Cargonerds module documentation.
The two paths in one sentence
Aspire → Azure Container Apps (ACA) is used for ephemeral per-branch environments and the release stage environment; dotnet publish zip → Azure App Service is the long-lived production path with deployment slots, warm-up swaps and a gated DB-migration handshake.
Deployment targets¶
| Target | How | Lifetime | Script / workflow |
|---|---|---|---|
| Local (.NET Aspire) | dotnet run --project src/Cargonerds.AppHost |
dev only | — (see Local Deployment) |
| Azure Container Apps | azd up via PowerShell wrapper |
ephemeral per branch + stage |
.scripts/DeployToAzureContainerApps.ps1, .github/workflows/azure-dev.yml |
| Azure App Service | dotnet publish zip + slot swap |
long-lived dev & prod | .scripts/PublishApp.ps1 + .scripts/DeployToAppServices.ps1, .github/workflows/azure-dev-app-services.yml |
| Helm / Docker Compose | helm install / docker compose up |
local-only, not CI | etc/helm/cargonerds, etc/docker-compose (see Helm & Kubernetes) |
The Aspire AppHost only ever publishes to Container Apps: src/Cargonerds.AppHost/azure.yaml pins host: containerapp, and azd reads that to choose the target.
name: cargonerds-apphost
services:
app:
language: dotnet
project: ./Cargonerds.AppHost.csproj
host: containerapp
App Service is not deployed via Aspire
The AppHost references Aspire.Hosting.Azure.AppService, but Program.cs never calls AddAzureContainerAppEnvironment or AddAzureAppServiceEnvironment. The App Service target is reached exclusively through the separate dotnet publish → zip → slot pipeline below. Treat the two paths as fully decoupled.
Architecture at a glance¶
flowchart TD
subgraph repo["Single repo · common.props version"]
AppHost["Cargonerds.AppHost<br/>Program.cs (resource graph)"]
Scripts[".scripts/*.ps1"]
end
AppHost -->|azd up<br/>host: containerapp| ACA
Scripts -->|DeployToAzureContainerApps.ps1| ACA
Scripts -->|PublishApp.ps1 +<br/>DeployToAppServices.ps1| AppSvc
subgraph ACA["Azure Container Apps · ephemeral + stage"]
direction LR
ACAenv["per-branch env<br/>rg-ABP-<env><br/><env>.cargonerds.dev"]
end
subgraph AppSvc["Azure App Service · long-lived prod/dev"]
direction LR
Slots["deployment slots<br/>spark-prod / spark-dev<br/>+ slot warm-up & swap"]
end
AppHost -.published image used by.-> ACA
classDef azure fill:#e7f0fb,stroke:#2c6fbb,color:#0b3b66;
class ACA,AppSvc azure;
Prerequisites¶
- .NET SDK 10 — the solution targets
net10.0(Cargonerds.AppHost.csproj→<TargetFramework>net10.0</TargetFramework>; CI usesactions/setup-dotnetwith10.x.x). - Aspire AppHost SDK 13.1.0 —
<Sdk Name="Aspire.AppHost.Sdk" Version="13.1.0" />with<IsAspireHost>true</IsAspireHost>. - Docker — for the SQL Server, Redis and RabbitMQ containers Aspire runs locally.
- Node.js 22 — for the Next.js
realtimefrontend (built into a standalone bundle for App Service, orPublishAsDockerFilefor ACA). - Azure CLI (
az) + Azure Developer CLI (azd) — for the Container Apps target. - PowerShell 7 (
pwsh) — all deployment scripts are PowerShell;PublishApp.ps1carries#requires -version 7.0(see gotchas). - An Azure subscription with rights to create resources and manage the
cargonerds.devDNS zone (held in resource grouprg-cargonerds-applications-core-infrastructure).
ABP client libraries
Every CI build installs the ABP CLI (dotnet tool install -g Volo.Abp.Cli) and runs abp install-libs before building, to restore the client-side libraries the hosts expect. Run the same locally if a fresh checkout fails to build its static assets.
Services deployed¶
The Aspire graph in src/Cargonerds.AppHost/Program.cs is the single source of truth. Each backend host and the frontend become their own container/app. The Aspire service names are defined in Cargonerds.Domain.Shared/CargonerdsConsts.cs (CargonerdsConsts.Aspire.Service):
| Aspire service | Project / source | Role |
|---|---|---|
auth |
Cargonerds.AuthServer |
OpenIddict auth server |
api |
Cargonerds.HttpApi.Host |
REST / OData API host |
admin |
Cargonerds.Blazor |
Blazor admin portal |
realtime |
frontend/realtime (Next.js) |
Customer-facing realtime frontend |
web |
Cargonerds.Web.Public |
Public marketing site |
db-migrator |
Cargonerds.DbMigrator |
Runs migrations + seed, then exits / is stopped |
documentation |
docs/Dockerfile (MkDocs) |
This docs site, added via AddDockerfile(...) with WithExplicitStart() |
The projects are added with a small helper that flips on the Aspire config block for every ABP host:
public static IResourceBuilder<ProjectResource> AddProjectWithDefaults<TProject>(
this IDistributedApplicationBuilder builder,
CargonerdsConsts.Aspire.Service service,
string? launchProfileName = null)
where TProject : IProjectMetadata, new() =>
builder
.AddProject<TProject>(service.Name, launchProfileName ?? ProjectName<TProject>())
.WithEnvironment(EnvVars.UseAspireConfig, "true"); // USE_ASPIRE_CONFIG=true
Backing services: Azure SQL (the ABP application DB) plus the Spark Hub DB, a Redis cache and RabbitMQ / Service Bus messaging. Locally these run as containers (AddAzureSqlServer(...).RunAsContainer(...), AddAzureRedis(...).RunAsContainer(...), AddRabbitMQ(...).PublishAsContainer()); in Azure they are provisioned by azd. Which Hub backend an environment talks to is selected by the Spark layer — see Deployment Configuration and the Configuration Reference.
Run vs. publish: how services find each other¶
Program.cs and ResourceBuilderExtensions.cs are written to behave differently in run mode (local dotnet run) and publish mode (azd up). The pivot is builder.ApplicationBuilder.ExecutionContext.IsPublishMode:
public static IResourceBuilder<ProjectResource> WithServiceReference(
this IResourceBuilder<ProjectResource> builder,
IResourceBuilder<IResourceWithEndpoints> target)
{
var configPath = $"services__{target.Resource.Name}__https__0";
return builder.ApplicationBuilder.ExecutionContext.IsPublishMode
? builder.WithEnvironment(configPath, GetDeploymentDomain(target)) // https://<name>.<DEPLOYMENT_DOMAIN>
: builder.WithEnvironment(configPath, target.GetFirstExistingEndpoint()); // live local endpoint
}
In publish mode every cross-service URL resolves to https://<service>.<DEPLOYMENT_DOMAIN> — which is exactly how ACA apps reach each other by their custom domain. DEPLOYMENT_DOMAIN is required in publish mode (EnvVars.DeploymentDomain throws if it is unset) and is exported by DeployToAzureContainerApps.ps1 as <env>.cargonerds.dev.
public static string DeploymentDomain =>
Environment.GetEnvironmentVariable("DEPLOYMENT_DOMAIN")
?? throw new InvalidOperationException(
"DEPLOYMENT_DOMAIN environment variable must be set in publish mode.");
Wait ordering is also encoded in the graph: db-migrator waits for the DB; auth and api wait for Redis, RabbitMQ and the DB and WaitForCompletion(migrator). These backend waits can be skipped for faster local starts with SKIP_WAITING_IN_BACKEND=1 (EnvVars.SkipWaitingInBackend). The full set of AppHost environment variables lives in src/Cargonerds.AppHost/EnvVars.cs.
Path 1 — Azure Container Apps (Aspire / azd)¶
Used for: ephemeral per-branch environments (<branch>.cargonerds.dev) and the release stage environment (stage.cargonerds.dev). Full details on Azure Container Apps Deployment.
The wrapper .scripts/DeployToAzureContainerApps.ps1 does far more than azd up:
- Resolve the environment name.
BranchUtils.ps1::Get-EnvironmentNamederives it from the current Git branch (or-EnvironmentName, or$CARGONERDS_APP_ENV_NAME) and sanitises it (Sanitize-BranchName: lowercase, every non-alphanumeric →-, collapsed dashes). From it the script computesazdEnvName = "ABP-<env>",resourceGroup = "rg-ABP-<env>"andfullDomain = "<env>.cargonerds.dev". - Serialise the deploy. It sets
AZD_UP_CONCURRENCY=1beforeazd up— mandatory sinceazure-dev-cli1.25.0, because paralleldotnet publishruns otherwise collide on the same outputs. - Provision + deploy. Exports
DEPLOYMENT_DOMAIN, creates the azd environment ingermanywestcentralif missing (azd env new), thenazd env select/azd env refresh/azd up. This provisions the Container Apps environment, one container app per externally-reachable service, plus Azure SQL, Redis and RabbitMQ, and pushes the images. - DNS + TLS via
SetupDnsAndCertificates.ps1(unless-SkipEnvSetup). - Aspire Dashboard RBAC via
GrantAspireDashboardOwner.ps1(Owner on the managed environment for the Aspire Dashboard Owners AAD group). - Azure AD redirect URI via
ConfigureAzureApplication.ps1 -Action add— registershttps://auth.<env>.cargonerds.dev/signin-oidcon the login app so OpenIddict sign-in works for the new domain.
$envName = Get-EnvironmentName -EnvironmentName $EnvironmentName
$azdEnvName = "ABP-$envName"
$fullDomain = "$envName.cargonerds.dev"
$resourceGroup = "rg-$azdEnvName"
# serial deploy: parallel `dotnet publish` collides since azure-dev-cli_1.25.0
$env:AZD_UP_CONCURRENCY = "1"
$env:DEPLOYMENT_DOMAIN = $fullDomain
azd up --environment $azdEnvName --no-prompt
DNS & TLS (managed-certificate flow)¶
For each externally-ingress ACA app, SetupDnsAndCertificates.ps1:
- writes a TXT record
asuid.<app>.<sub>= the app'scustomDomainVerificationId, - adds a CNAME from
<app>.<env>.cargonerds.devto the app FQDN, - binds the hostname and provisions a managed certificate (
--validation-method CNAME), - removes the validation TXT record.
A CNAME for aspire-dashboard.<sub> → aspire-dashboard.ext.<defaultDomain> is also created so the dashboard is reachable on the custom domain.
Ephemeral environments, tagging & teardown¶
The azure-dev.yml workflow (push to main, manual dispatch) runs the script with -SkipEnvSetup $true, then tags the resource group with deployedAt, branch, isProtectedBranch, isManualDeployment, autoTeardownEnabled, commitSha and workflowRunId. autoTeardownEnabled=true only for a manual deploy of a non-protected branch. PROTECTED_BRANCHES="main|production|staging" may not be used as a manual override.
teardown.yml (daily cron 0 2 * * * + manual) lists rg-ABP-*, skips autoTeardownEnabled != true, and deletes resource groups older than max_age_days (default 7), also purging soft-deleted Key Vaults to free their names. The local equivalent is TeardownAzureContainerApps.ps1 (azd down --force + DNS cleanup + AD redirect removal).
Branch → environment naming
Feature branch feat/My_Thing becomes env feat-my-thing, RG rg-ABP-feat-my-thing, and URLs like https://api.feat-my-thing.cargonerds.dev. main, production and staging are protected and never auto-torn-down.
Path 2 — Azure App Service (publish + slot swap)¶
Used for: the long-lived spark-dev and spark-prod App Services — the primary production path. Full details on Azure App Service.
Build the zips — PublishApp.ps1¶
Requires PowerShell 7 (PS5's Compress-Archive produces zips Azure App Service rejects). It publishes each .NET host -c Release -r linux-x64 and zips from inside the publish directory; for realtime it runs npm ci + npm run build, copies the Next.js .next/standalone bundle + node_modules + static assets, normalises timestamps to UTC, and zips via ZipFile.CreateFromDirectory. The outputs are ./publish/{Authenticator,Api,Realtime,Admin,DbMigrator}.zip.
Deploy the zips — DeployToAppServices.ps1¶
-AzureEnvironment selects the target; everything else (resource group, app names, slot names) is derived:
-AzureEnvironment |
Resource group | appPrefix |
Slot |
|---|---|---|---|
Prod-Staging |
rg-spark-prod |
prod |
staging |
Dev-HubProd |
rg-spark-dev |
dev |
hub-prod |
Dev-HubTest |
rg-spark-dev |
dev |
hub-test |
App names follow spark-<prefix>-<service> and slot names spark-<prefix>-<slot>-<service>. The script migrates the DB first (unless -SkipDbMigration), then deploys auth, api, realtime and admin (the migrator is handled by the migration step, not this loop):
az webapp deploy `
--src-path $zipPath -g $resourceGroup -n $appServiceName -s $slotName `
--type zip --track-status false --clean true
--track-status false is deliberate: az webapp deploy frequently reports failure on a successful deploy. On a non-zero exit the script falls back to Kudu SCM log verification (VerifyDeploymentFromScmLogs, polling https://<app>[-<slot>].scm.azurewebsites.net/api/deployments) to detect "CLI said fail but the deploy actually succeeded", retries up to 3×, then waits for HTTP 200.
DB-migration handshake — MigrateAppServiceDb.ps1¶
The DbMigrator runs as its own App Service slot and migrations are an explicit, gated step, never an app-startup side-effect. migrateDb:
- Refuses to proceed unless the migrator slot is
Stopped. - Generates a random 50-char token and sets it as the
PIPELINE_AUTH_TOKENapp setting. - Deploys
DbMigrator.zip(same retry + SCM logic) andaz webapp starts the slot. - Polls
https://<migrator>/migration/status?token=...untilstate == Completed(Failedaborts). - In a
finallyit stops the slot and deletes the token on every iteration, so the token is short-lived. An optionalAZURE_ENVIRONMENToverride (used by the swap path) is also reverted infinally.
The migration status endpoint is token-guarded
/migration/status is exposed by the ABP DbMigrator host and only answers when the caller presents the one-time PIPELINE_AUTH_TOKEN. This is what lets CI run migrations against a live slot without leaving an open endpoint behind.
Slot warm-up & swap — SwapAppServiceSlots.ps1¶
To go live, the script swaps the slot into the production slot with zero-downtime warm-up:
- Run a fresh migration against the production-slot DB (passing the production
AZURE_ENVIRONMENTread off the migrator slot). - For all four services:
az webapp deployment slot swap ... --action preview(warm-up under production settings). -
Poll service-specific health endpoints until all return 200:
Service Health endpoint auth/.well-known/openid-configurationapi/api/abp/application-configurationrealtime/admin/ -
... --action swapto shift traffic.
The health base URL is chosen by preference *.rohlig.com > *.cargonerds.dev > other custom domain > *.azurewebsites.net. On error the script prints the --action reset commands to cancel a stuck preview (manual rollback).
Environment strategy¶
Every deployed instance is described by two orthogonal environment variables; this is the heart of the deployment model and is documented in full on Deployment Configuration and the Configuration Reference.
| Variable | Question it answers | What it selects |
|---|---|---|
SPARK_ENVIRONMENT |
Which Hub backend (DB / storage / bus)? | appsettings.spark.<env>.json |
AZURE_ENVIRONMENT |
Which deployment shape (URLs, app DB, cache)? | src/Cargonerds.ServiceDefaults/appsettings.azure.<dotted-name>.json |
AZURE_ENVIRONMENT uses a dotted name that progressively layers files (dev.hub-prod loads appsettings.azure.dev.json then appsettings.azure.dev.hub-prod.json). The App Service pipeline maps friendly names to these dotted names via the -AzureEnvironment parameter. The Spark run-mode value is passed onto the API container by the AppHost (PassConfigurationValue(EnvVars.SparkEnvironmentName)), and SetupDbRunMode (SparkDbRunModeConfiguration) lets a local AppHost point at an Azure Spark DB — or a suffixed copy via APPHOST_AZURE_DATABASE_COPY_SUFFIX — without touching the real database.
Environments by path:
| Environment | Path | Domain / slot |
|---|---|---|
| Per-branch (ephemeral) | Container Apps | <branch>.cargonerds.dev |
stage (release) |
Container Apps | stage.cargonerds.dev (SparkEnvironment=Test) |
| Dev HubTest / HubProd | App Service | hub-test / hub-prod slots of spark-dev |
| Prod Staging → Production | App Service | staging slot of spark-prod, swapped into production |
Never point a local process at production data
Setting AZURE_ENVIRONMENT=prod (or any prod.*) on a developer machine connects to production data. Use dev or prod.staging (read-only Spark user) for investigation. White-label config and third-party keys come from appsettings.rohlig.json (path set via WHITE_LABEL_SETTINGS_PATH); see Theming & White-Labeling.
CI/CD¶
| Workflow | Trigger | Action |
|---|---|---|
azure-dev.yml |
push to main, manual dispatch |
Deploy an Aspire / Container Apps env (named after the sanitised branch), then set up DNS + TLS + dashboard RBAC + AD redirect |
azure-dev-app-services.yml |
push to main, manual dispatch |
Bump version, publish, deploy to App Service (Dev-HubTest then Dev-HubProd) with a swap |
release-deploy-app-services.yml |
GitHub release published | Version-stamp, deploy to the stage Aspire env and the prod staging slot, manual approval gate, then production swap |
pr-validation.yml |
pull request | Build / test / format gate (.NET + frontend) |
teardown.yml |
scheduled (0 2 * * *) / manual |
Tear down ephemeral RGs by age + tags, purge soft-deleted Key Vaults |
docs.yml |
docs changes | mkdocs build --strict → GitHub Pages |
Authentication to Azure uses OIDC federated credentials (AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_SUBSCRIPTION_ID); GitHub environment gates (stage, production) protect the release path. Feature branches get ephemeral Container Apps environments; main, production and staging are protected (no auto-teardown).
Dev App Service pipeline (push to main)¶
flowchart LR
A["update job<br/>UpdateVersion.ps1 -incVersion build<br/>(commit + push)"] --> B["PublishApp.ps1"]
B --> C["Deploy Dev-HubTest<br/>(+ migration)"]
C --> D["Deploy Dev-HubProd<br/>(+ migration)"]
D --> E["Swap HubTest → production"]
E --> F["Redeploy Dev-HubTest<br/>-SkipDbMigration"]
The update job bumps the 4th version part in common.props and pushes it, then guards against re-triggering itself (if: !startsWith(head_commit.message, 'chore: bump version to ')). After the swap, HubTest is redeployed with -SkipDbMigration because the swap moved its content into production and the migration already ran.
Release pipeline (on release published)¶
UpdateVersion.ps1 -version <tag> stamps the exact version → deployAspire to the fixed stage env (SparkEnvironment=Test) → deployAppService to Prod-Staging → approve (GitHub production environment, manual) → swap (SwapAppServiceSlots.ps1 -AzureEnvironment Prod-Staging, staging → production).
Local Docker / Helm (local-only)¶
A third option exists purely for offline local development and is not used by CI:
etc/docker-compose/docker-compose.ymlruns the full stack (api/auth/web/blazor/migrator) withazure-sql-edge+redis. Hosts useDockerfile.local(ports 8080/8081).etc/helm/cargonerds/is an ABP-Studio-style umbrella chart (subcharts for authserver, blazor, httpapihost, webpublic, dbmigrator, rabbitmq, redis, sqlserver) for Docker Desktop Kubernetes. Each subchart injects ABP environment variables (App__SelfUrl,ConnectionStrings__Defaultwith[RELEASE_NAME]token replacement,AuthServer__Authority,RabbitMQ__Connections__Default__HostName,StringEncryption__DefaultPassPhrase) and a/health-statusreadiness probe.
See Helm & Kubernetes for setup (hosts-file entries, local TLS).
Gotchas¶
Runtime container images target .NET 9, the solution targets .NET 10
Every src/**/Dockerfile uses mcr.microsoft.com/dotnet/aspnet:9.0 and copies bin/Release/net9.0/publish/, while the solution is net10.0. The App Service path uses dotnet publish (framework-dependent on the App Service runtime), so this mainly bites the container paths (ACA / compose / Helm). Verify the base image before relying on container images.
AZD_UP_CONCURRENCY=1is mandatory for ACA deploys; without it paralleldotnet publishruns collide (regression sinceazure-dev-cli1.25.0). A bareazd upoutside the wrapper will not set it.DEPLOYMENT_DOMAINmust be set in publish mode or the AppHost throws. CI sets it; a bareazd upwithout the wrapper script fails.- PowerShell 7 required for
PublishApp.ps1— PS5Compress-Archiveyields zips Azure App Service refuses (#requires -version 7.0). az webapp deployreports false failures. The SCM-log verification (VerifyDeploymentFromScmLogs) is what keeps the pipeline green;--track-status falseis set precisely because tracking is flaky.- Two Azure auth stacks. Most scripts use the
azCLI, butSetupAppServiceCustomDomains.ps1and the RBAC/dashboard scripts mix inAz.*PowerShell cmdlets (Get-AzWebApp,New-AzWebAppCertificate) and degrade gracefully if those modules are absent. - Slot juggling after swap. HubTest is redeployed post-swap with
-SkipDbMigrationbecause the swap moved its content into production. - Hardcoded secrets/IDs in source. The AAD app id, dashboard group GUIDs, a static
dbPassword "securePassword-2026!"andInternalServicePassword "UnsecurePassword"are checked in (Program.cs). The two paths do not use Azure Key Vault — secrets come from GitHub Actions secrets (CI) and the deployed JSON config layers. Treat the checked-in values as non-production-grade.
Next steps¶
- Local Deployment — run the full stack with Aspire on your machine
- Azure Container Apps — the
azd/ ephemeral-env path in depth - Azure App Service — the publish + slot-swap production path
- Helm & Kubernetes — local-only k8s / Compose
- CI/CD Pipelines — per-workflow reference
- Deployment Configuration — how config reaches each environment
- Configuration Reference — the full Spark / Azure layering model
- .NET Aspire Integration — the AppHost resource graph