Skip to content

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.

src/Cargonerds.AppHost/azure.yaml
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-&lt;env&gt;<br/>&lt;env&gt;.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 &amp; 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 uses actions/setup-dotnet with 10.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 realtime frontend (built into a standalone bundle for App Service, or PublishAsDockerFile for ACA).
  • Azure CLI (az) + Azure Developer CLI (azd) — for the Container Apps target.
  • PowerShell 7 (pwsh) — all deployment scripts are PowerShell; PublishApp.ps1 carries #requires -version 7.0 (see gotchas).
  • An Azure subscription with rights to create resources and manage the cargonerds.dev DNS zone (held in resource group rg-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:

src/Cargonerds.AppHost/Extensions/ResourceBuilderExtensions.cs
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:

src/Cargonerds.AppHost/Extensions/ResourceBuilderExtensions.cs
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.

src/Cargonerds.AppHost/EnvVars.cs
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:

  1. Resolve the environment name. BranchUtils.ps1::Get-EnvironmentName derives 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 computes azdEnvName = "ABP-<env>", resourceGroup = "rg-ABP-<env>" and fullDomain = "<env>.cargonerds.dev".
  2. Serialise the deploy. It sets AZD_UP_CONCURRENCY=1 before azd up — mandatory since azure-dev-cli 1.25.0, because parallel dotnet publish runs otherwise collide on the same outputs.
  3. Provision + deploy. Exports DEPLOYMENT_DOMAIN, creates the azd environment in germanywestcentral if missing (azd env new), then azd 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.
  4. DNS + TLS via SetupDnsAndCertificates.ps1 (unless -SkipEnvSetup).
  5. Aspire Dashboard RBAC via GrantAspireDashboardOwner.ps1 (Owner on the managed environment for the Aspire Dashboard Owners AAD group).
  6. Azure AD redirect URI via ConfigureAzureApplication.ps1 -Action add — registers https://auth.<env>.cargonerds.dev/signin-oidc on the login app so OpenIddict sign-in works for the new domain.
.scripts/DeployToAzureContainerApps.ps1 (excerpt)
$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:

  1. writes a TXT record asuid.<app>.<sub> = the app's customDomainVerificationId,
  2. adds a CNAME from <app>.<env>.cargonerds.dev to the app FQDN,
  3. binds the hostname and provisions a managed certificate (--validation-method CNAME),
  4. 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):

.scripts/DeployToAppServices.ps1 (excerpt)
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:

  1. Refuses to proceed unless the migrator slot is Stopped.
  2. Generates a random 50-char token and sets it as the PIPELINE_AUTH_TOKEN app setting.
  3. Deploys DbMigrator.zip (same retry + SCM logic) and az webapp starts the slot.
  4. Polls https://<migrator>/migration/status?token=... until state == Completed (Failed aborts).
  5. In a finally it stops the slot and deletes the token on every iteration, so the token is short-lived. An optional AZURE_ENVIRONMENT override (used by the swap path) is also reverted in finally.

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:

  1. Run a fresh migration against the production-slot DB (passing the production AZURE_ENVIRONMENT read off the migrator slot).
  2. For all four services: az webapp deployment slot swap ... --action preview (warm-up under production settings).
  3. Poll service-specific health endpoints until all return 200:

    Service Health endpoint
    auth /.well-known/openid-configuration
    api /api/abp/application-configuration
    realtime /
    admin /
  4. ... --action swap to 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-Stagingapprove (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.yml runs the full stack (api / auth / web / blazor / migrator) with azure-sql-edge + redis. Hosts use Dockerfile.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__Default with [RELEASE_NAME] token replacement, AuthServer__Authority, RabbitMQ__Connections__Default__HostName, StringEncryption__DefaultPassPhrase) and a /health-status readiness 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.

src/Cargonerds.HttpApi.Host/Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:9.0
COPY bin/Release/net9.0/publish/ app/
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:80
ENTRYPOINT ["dotnet", "Cargonerds.HttpApi.Host.dll"]
  • AZD_UP_CONCURRENCY=1 is mandatory for ACA deploys; without it parallel dotnet publish runs collide (regression since azure-dev-cli 1.25.0). A bare azd up outside the wrapper will not set it.
  • DEPLOYMENT_DOMAIN must be set in publish mode or the AppHost throws. CI sets it; a bare azd up without the wrapper script fails.
  • PowerShell 7 required for PublishApp.ps1 — PS5 Compress-Archive yields zips Azure App Service refuses (#requires -version 7.0).
  • az webapp deploy reports false failures. The SCM-log verification (VerifyDeploymentFromScmLogs) is what keeps the pipeline green; --track-status false is set precisely because tracking is flaky.
  • Two Azure auth stacks. Most scripts use the az CLI, but SetupAppServiceCustomDomains.ps1 and the RBAC/dashboard scripts mix in Az.* PowerShell cmdlets (Get-AzWebApp, New-AzWebAppCertificate) and degrade gracefully if those modules are absent.
  • Slot juggling after swap. HubTest is redeployed post-swap with -SkipDbMigration because the swap moved its content into production.
  • Hardcoded secrets/IDs in source. The AAD app id, dashboard group GUIDs, a static dbPassword "securePassword-2026!" and InternalServicePassword "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