Skip to content

Azure Container Apps Deployment

This target deploys the full Aspire application to Azure Container Apps (ACA) using the Azure Developer CLI (azd), wrapped by .scripts/DeployToAzureContainerApps.ps1, which also automates custom DNS entries and managed TLS certificates in the cargonerds.dev zone, Aspire Dashboard RBAC, and Azure AD redirect-URI registration.

It is the target used by the azure-dev.yml GitHub workflow (push to main or manual dispatch) and — via the same wrapper — by the release pipeline for the long-lived stage environment. For the App Service production path instead, see Azure App Service; for the high-level comparison of both paths, see the Deployment Overview.

In one sentence

azd up publishes the .NET Aspire graph in Cargonerds.AppHost to a Container Apps environment; the PowerShell wrapper turns the resulting apps into addressable https://<service>.<env>.cargonerds.dev URLs with TLS, and feature branches get ephemeral, auto-torn-down environments.

Why Container Apps here

The same Aspire AppHost that you run locally is the deployable artifact. ACA is chosen because:

  • It runs the Aspire app graph (one container app per externally-reachable service plus managed Azure SQL, Redis and a RabbitMQ container) without hand-writing Kubernetes manifests.
  • It supports managed certificates and custom-domain binding, which the DNS automation drives, so every per-branch environment gets real TLS on *.cargonerds.dev.
  • It is cheap and fast to provision and tear down, which is exactly what short-lived per-branch review environments need.

ACA is the only target Aspire publishes to in this repo. azd selects it purely from azure.yaml:

src/Cargonerds.AppHost/azure.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: cargonerds-apphost
services:
  app:
    language: dotnet
    project: ./Cargonerds.AppHost.csproj
    host: containerapp

App Service is not reached via Aspire

Cargonerds.AppHost.csproj references Aspire.Hosting.Azure.AppService, but Program.cs never calls AddAzureContainerAppEnvironment or AddAzureAppServiceEnvironment. The host: containerapp line above is what makes azd emit Container Apps. The App Service target is a fully separate dotnet publish → zip → slot pipeline (Azure App Service).

Prerequisites

  • .NET SDK 10 (the AppHost targets net10.0; CI uses actions/setup-dotnet 10.x.x), Docker, Node.js 22 (for the realtime Next.js frontend)
  • Azure CLI (az login) and Azure Developer CLI (azd)
  • PowerShell 7 (pwsh)
  • Rights to create resources in the subscription and manage DNS in the cargonerds.dev zone (held in resource group rg-cargonerds-applications-core-infrastructure)

ABP client libraries first

CI installs the ABP CLI (dotnet tool install -g Volo.Abp.Cli) and runs abp install-libs before building, so the ABP hosts have their client-side libraries. Run the same locally if a fresh checkout fails to build its static assets.

Quick deploy

cd .scripts
./DeployToAzureContainerApps.ps1 -EnvironmentName 'my-feature' -SparkEnvironment Default

If you omit -EnvironmentName, the script presents an interactive menu (current Git branch, your $CARGONERDS_APP_ENV_NAME, or a custom name — see Environment naming).

Script parameters

DeployToAzureContainerApps.ps1 accepts (with defaults):

Parameter Default Purpose
-EnvironmentName (prompts) — current Git branch (sanitised) Environment / subdomain name
-SparkEnvironment Default (also Test) Which Hub backend the env targets (ValidateSet)
-ProjectPath ../src/Cargonerds.AppHost The AppHost project
-DnsZoneName cargonerds.dev DNS zone
-DnsResourceGroup rg-cargonerds-applications-core-infrastructure RG holding the DNS zone
-MemberGroup a2445600-42f9-4876-819e-99275a865a66 AAD Aspire Dashboard Owners group for RBAC
-SkipEnvSetup $false Skip the post-deploy steps (DNS/cert + dashboard RBAC + AD redirect). Used by CI, which runs DNS setup as a separate job.

The environment name becomes part of the resource group (rg-ABP-<env>), the azd environment (ABP-<env>) and the subdomain (<env>.cargonerds.dev).

-SkipEnvSetup does not skip the azd environment bootstrap

Even with -SkipEnvSetup $true the script still creates/selects the azd environment and runs azd up. What it skips is everything after the deploy: SetupDnsAndCertificates.ps1, GrantAspireDashboardOwner.ps1 and ConfigureAzureApplication.ps1. CI passes -SkipEnvSetup $true and then runs DNS/cert setup in its own configure job (see CI deployment).

Environment model and naming

Every ACA deployment is one azd environment, which maps 1:1 to an Azure resource group, a Container Apps managed environment, and a DNS subdomain. The names are all derived from a single sanitised environment name:

Derived value Pattern Example (env = feat-my-thing)
azd environment ABP-<env> ABP-feat-my-thing
Resource group rg-ABP-<env> rg-ABP-feat-my-thing
Deployment domain <env>.cargonerds.dev feat-my-thing.cargonerds.dev
Per-service URL https://<service>.<env>.cargonerds.dev https://api.feat-my-thing.cargonerds.dev

The name is resolved by BranchUtils.ps1. Sanitize-BranchName lowercases, replaces every non-alphanumeric run with a single -, and trims leading/trailing dashes — so branch feat/My_Thingfeat-my-thing. When -EnvironmentName is not supplied, Get-EnvironmentName shows an interactive PS-Menu (installing the module if missing) offering the current branch, $CARGONERDS_APP_ENV_NAME, or a typed name:

.scripts/BranchUtils.ps1 (excerpt)
function Sanitize-BranchName {
    param([string]$branch)
    return ($branch.ToLower() -replace "[^a-zA-Z0-9]", "-" -replace "^-+|-+$" -replace "-+", "-")
}

Two environment axes are orthogonal

The ACA environment name above answers where (which RG / domain). It is separate from SPARK_ENVIRONMENT (which Hub backendDefault/Test here) and AZURE_ENVIRONMENT (which deployment shape). The Spark axis is set by -SparkEnvironment; the full layering model lives in Deployment Configuration and the Configuration Reference.

Services that get deployed

The Aspire graph in src/Cargonerds.AppHost/Program.cs is the single source of truth for what becomes a container app. Service names are defined in Cargonerds.Domain.Shared/CargonerdsConsts.cs (CargonerdsConsts.Aspire.Service):

Aspire service Project / source Externally reachable?
auth Cargonerds.AuthServer yes — OpenIddict auth server
api Cargonerds.HttpApi.Host yes — REST / OData API host
admin Cargonerds.Blazor yes — Blazor admin portal
web Cargonerds.Web.Public yes — public site
realtime frontend/realtime (Next.js) yes — customer frontend
db-migrator Cargonerds.DbMigrator no — runs migrations, then exits
documentation docs/Dockerfile (MkDocs) yes — this docs site (WithExplicitStart())

Only apps with external ingress get a custom domain and certificate (the DNS script skips the rest — db-migrator in particular). Backing services — Azure SQL, Redis and RabbitMQ — are provisioned by azd from the AppHost graph; locally they run as containers.

How the script works end to end

flowchart TD
    start(["./DeployToAzureContainerApps.ps1"]) --> name["Resolve env name<br/>(BranchUtils.ps1)"]
    name --> derive["azdEnv = ABP-&lt;env&gt;<br/>rg = rg-ABP-&lt;env&gt;<br/>domain = &lt;env&gt;.cargonerds.dev"]
    derive --> conc["AZD_UP_CONCURRENCY=1<br/>DEPLOYMENT_DOMAIN=&lt;domain&gt;"]
    conc --> envnew["azd env new (germanywestcentral)<br/>if not exists"]
    envnew --> up["azd env select / refresh<br/>azd up"]
    up --> skip{"-SkipEnvSetup ?"}
    skip -- "true (CI)" --> done(["print URLs · exit"])
    skip -- "false (local)" --> dns["SetupDnsAndCertificates.ps1<br/>DNS + managed TLS"]
    dns --> rbac["GrantAspireDashboardOwner.ps1<br/>dashboard RBAC"]
    rbac --> redirect["ConfigureAzureApplication.ps1 -Action add<br/>AAD redirect URI"]
    redirect --> done
    classDef azure fill:#e7f0fb,stroke:#2c6fbb,color:#0b3b66;
    class up,envnew,dns azure;
  1. Resolve the environment name (-EnvironmentName, the interactive menu, or $CARGONERDS_APP_ENV_NAME) and compute azdEnvName, resourceGroup and fullDomain.
  2. Serialise the deploy and export the domain. It sets AZD_UP_CONCURRENCY=1 and DEPLOYMENT_DOMAIN=<env>.cargonerds.dev before provisioning.
  3. Create/select the azd environment in germanywestcentral if it does not exist, then azd env select / azd env refresh / azd up. This provisions the Container Apps environment plus Azure SQL, Redis and RabbitMQ, and deploys one container app per service.
  4. Post-deploy (unless -SkipEnvSetup) — DNS + TLS, dashboard RBAC, AD redirect URI.
  5. Print the resulting URLs, e.g. https://api.<env>.cargonerds.dev, https://auth.<env>.cargonerds.dev.
.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
if ($SparkEnvironment -ne "Default") { $env:SPARK_ENVIRONMENT = $SparkEnvironment }

$envExists = azd env list -o json | ConvertFrom-Json | Where-Object { $_.name -eq $azdEnvName }
if (-not $envExists) {
    $subscriptionId = az account show --query id -o tsv
    azd env new $azdEnvName --location "germanywestcentral" --no-prompt --subscription $subscriptionId
}
azd env select $azdEnvName
azd env refresh
azd up --environment $azdEnvName --no-prompt

AZD_UP_CONCURRENCY=1 is mandatory

The script forces serial publish. Since azure-dev-cli 1.25.0, parallel dotnet publish runs collide on shared outputs. A bare azd up outside this wrapper will not set this and can fail. The wrapper also exports DEPLOYMENT_DOMAIN — without it the AppHost throws in publish mode (see How services find each other).

How services find each other in publish mode

Program.cs and ResourceBuilderExtensions.cs behave differently in run mode (local dotnet run) and publish mode (azd up). The pivot is builder.ApplicationBuilder.ExecutionContext.IsPublishMode. In publish mode every cross-service URL is rewritten to the custom domain — which is exactly how ACA apps reach each other:

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
}

private static string GetDeploymentDomain(IResourceBuilder<IResourceWithEndpoints> target, string suffix = "")
    => $"https://{target.Resource.Name}.{EnvVars.DeploymentDomain}{suffix}";

EnvVars.DeploymentDomain reads DEPLOYMENT_DOMAIN and throws if it is unset — this is why the wrapper exports it:

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.");

Each ABP host also receives USE_ASPIRE_CONFIG=true (via AddProjectWithDefaults<T>), which flips the host onto its Aspire-specific configuration block; without it the default ABP config (incompatible with the AppHost wiring) is used. See the .NET Aspire integration page for the full run-vs-publish graph and the Database Migrations page for the db-migrator wait ordering.

DNS and TLS automation

After azd up, externally-reachable apps still answer only on their default ACA FQDN. The managed-certificate flow in SetupDnsAndCertificates.ps1 gives each one a custom *.cargonerds.dev hostname with TLS. For every container app whose ingress is external it:

  1. reads the environment's customDomainVerificationId (the ASUID) and the app's ingress FQDN (DefaultDomain);
  2. writes a TXT record asuid.<app>.<env> = that ASUID, for domain ownership validation;
  3. writes a CNAME <app>.<env>.cargonerds.dev → the app FQDN (--ttl 60);
  4. adds the hostname to the app (az containerapp hostname add);
  5. binds it and provisions a managed certificate with --validation-method CNAME;
  6. removes the validation TXT record.
.scripts/SetupDnsAndCertificates.ps1 (the bind step)
az containerapp hostname bind `
    --resource-group $ResourceGroup `
    --name $name `
    --hostname $ContainerDomain `
    --environment $ContainerAppsEnvironmentId `
    --validation-method CNAME `
    -o none

If a hostname already exists, the script is idempotent: it leaves the binding alone and only re-points the CNAME if the app's FQDN changed (e.g. after a redeploy). Apps without external ingress are skipped:

.scripts/SetupDnsAndCertificates.ps1 (skip non-external apps)
if ($ContainerApp.properties.configuration.ingress.external -ne $true) {
    Write-Host "Skipping $name as it is not externally accessible."
    continue
}

Finally it adds one more CNAME so the Aspire Dashboard is reachable on the custom subdomain:

.scripts/SetupDnsAndCertificates.ps1 (dashboard CNAME)
$ContainerAppsEnvironmentUrl = "aspire-dashboard.ext." + $ContainerAppsEnvironment.properties.defaultDomain
az network dns record-set cname set-record `
    --resource-group $dnsResourceGroup --zone-name $dnsZoneName `
    --record-set-name "aspire-dashboard.$SubDomain" `
    --cname $ContainerAppsEnvironmentUrl --ttl 60 -o none

DNS lives in a different resource group

The zone cargonerds.dev is in rg-cargonerds-applications-core-infrastructure, not in the per-environment rg-ABP-<env>. The script targets the zone RG for record operations and the app RG for hostname/cert operations.

Dashboard RBAC and the auth redirect URI

Two more post-deploy steps run only when -SkipEnvSetup is $false:

  • GrantAspireDashboardOwner.ps1 grants access on the Container Apps managed environment to the Aspire Dashboard Owners AAD group passed via -MemberGroup (a2445600-42f9-4876-819e-99275a865a66). Despite the file name, its default -Role is Contributor and it assigns the role to each member of the group individually (recursing into nested groups).
  • ConfigureAzureApplication.ps1 -Action add registers https://auth.<env>.cargonerds.dev/signin-oidc as a redirect URI on the shared AAD login app d9422a95-35c3-402d-abe6-acf8474bda54, so external OpenIddict sign-in works for the new domain.

After deployment a local run waits for input: type exit to keep the environment, or destroy to tear it down.

Ephemeral feature-branch environments

The headline feature of this path is disposable per-branch review environments. Each branch deploy lands in its own rg-ABP-<env> so it is isolated and trivially removable. The azure-dev.yml workflow tags every environment's resource group with deployment metadata, and a scheduled workflow reaps old ones.

flowchart LR
    push["push branch<br/>or workflow_dispatch"] --> dep["azure-dev.yml<br/>deploy + configure"]
    dep --> tag["az group update --tags<br/>autoTeardownEnabled, branch, age…"]
    tag --> live["rg-ABP-&lt;env&gt; live<br/>&lt;env&gt;.cargonerds.dev"]
    live --> cron["teardown.yml<br/>daily 0 2 * * *"]
    cron --> check{"autoTeardownEnabled<br/>&& age &gt;= max_age_days?"}
    check -- yes --> del["az group delete<br/>+ purge soft-deleted Key Vaults"]
    check -- no --> live
  • Tagging — the configure job sets deployedAt, triggeredBy, branch, isProtectedBranch, isManualDeployment, autoTeardownEnabled, commitSha and workflowRunId on the RG. autoTeardownEnabled=true only for a manual deploy of a non-protected branch.
  • Protected branchesmain, production and staging are protected: they are never auto-torn-down and cannot be supplied as a manual override environment name.
  • Reapingteardown.yml (daily cron 0 2 * * * + manual) lists rg-ABP-*, skips any RG with autoTeardownEnabled != true, deletes those older than max_age_days (default 7), and purges soft-deleted Key Vaults so their names can be reused. (Manual teardown requires typing DELETE.)

See the CI/CD Pipelines page for the full per-workflow reference.

Tear down

cd .scripts
./TeardownAzureContainerApps.ps1 -EnvironmentName 'my-feature'

TeardownAzureContainerApps.ps1 is the local equivalent of the scheduled reaper:

  1. azd down --environment ABP-<env> --force — deletes all provisioned Azure resources.
  2. CleanupDnsEntries.ps1 — removes dangling CNAMEs in the zone (only untagged records whose target no longer resolves via Resolve-DnsName; tagged records are always skipped).
  3. ConfigureAzureApplication.ps1 -Action remove — removes the signin-oidc redirect URI.

Two different dashboard group GUIDs

DeployToAzureContainerApps.ps1 defaults -MemberGroup to a2445600-42f9-4876-819e-99275a865a66, while TeardownAzureContainerApps.ps1 defaults it to 31fb2023-192b-4905-96f5-b862ade7a363. They are easy to confuse; the deploy-side group is the one that gets the RBAC assignment.

The teardown.yml and cleanup-dns.yml workflows automate teardown of ephemeral environments (note: the DNS-cleanup workflow ships disabled with a .inactive filename suffix, so the daily DNS sweep it contains does not run on its own — the cleanup logic only runs inside teardown).

CI deployment (azure-dev.yml)

The Deploy Aspire Environment workflow triggers on push to main and on manual dispatch (with optional environment_name and spark_environment inputs). It has three jobs:

flowchart LR
    R["resolve-environment<br/>sanitise branch / override"] --> D["deploy (env: stage)<br/>azd up via wrapper"]
    D --> C["configure (env: stage)<br/>DNS/TLS + tag RG"]
  1. resolve-environment — sanitises the pushed branch (or the workflow_dispatch override) into env_name, and computes is_protected_branch / is_manual_deployment. A manual override matching PROTECTED_BRANCHES="main|production|staging" is rejected:

    .github/workflows/azure-dev.yml (override guard)
    if echo "$INPUT_ENV_NAME" | grep -qiE "^($PROTECTED_BRANCHES)$"; then
      echo "::error::The environment name '$INPUT_ENV_NAME' is a protected branch name and cannot be used as a manual override."
      exit 1
    fi
    
  2. deploy (GitHub environment stage) — installs azd + .NET 10 + the ABP CLI (abp install-libs), logs in to Azure via OIDC, runs azd auth login, then invokes the wrapper with -SkipEnvSetup $true:

    .github/workflows/azure-dev.yml (deploy step)
    - name: Deploy Aspire
      shell: pwsh
      working-directory: ./.scripts
      run: >
        ./DeployToAzureContainerApps.ps1
        -EnvironmentName '${{ needs.resolve-environment.outputs.env_name }}'
        -SkipEnvSetup $true
        -SparkEnvironment '${{ inputs.spark_environment || 'Default' }}'
    
  3. configure (GitHub environment stage) — runs SetupDnsAndCertificates.ps1 against rg-ABP-<env> (because deploy skipped it), then tags the RG with the deployment metadata described in Ephemeral environments.

Why DNS runs in a separate job

CI deliberately splits the deploy and the DNS/cert setup. deploy passes -SkipEnvSetup $true so the wrapper only does azd up; the configure job then calls SetupDnsAndCertificates.ps1 directly. (CI does not run the dashboard-RBAC or AD-redirect steps that a local -SkipEnvSetup $false run would.) Authentication uses OIDC federated credentials (AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_SUBSCRIPTION_ID), plus AZURE_CLIENT_SECRET for azd auth login.

The stage environment uses the same wrapper

The release pipeline (release-deploy-app-services.yml, on a published GitHub release) reuses this exact script for the long-lived stage environment — it just pins the name and the Spark backend:

.github/workflows/release-deploy-app-services.yml (deployAspire step)
./DeployToAzureContainerApps.ps1
  -EnvironmentName 'stage'
  -SkipEnvSetup $true
  -SparkEnvironment 'Test'

So stage.cargonerds.dev is provisioned through the identical ACA path, with SPARK_ENVIRONMENT=Test. The App Service half of that release pipeline (slot warm-up + swap) is documented on Azure App Service.

Customising

  • Azure region — defaults to germanywestcentral (the --location for azd env new). Change it in the script if you provision elsewhere.
  • DNS zone / resource group — change the script parameters (-DnsZoneName, -DnsResourceGroup) or the $dnsZoneName / $dnsResourceGroup variables in SetupDnsAndCertificates.ps1.
  • Spark backend-SparkEnvironment Test points the environment at the Test Hub backend; see Deployment Configuration.

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. Because ACA runs these container images, this mismatch bites the container path specifically. Verify the base image before relying on container builds.

  • AZD_UP_CONCURRENCY=1 is mandatory — a bare azd up outside the wrapper can fail from colliding parallel publishes (regression since azure-dev-cli 1.25.0).
  • DEPLOYMENT_DOMAIN must be set in publish mode — the AppHost throws otherwise. The wrapper exports it; a bare azd up without it fails.
  • -SkipEnvSetup skips post-deploy steps, not the azd bootstrap — see the warning under Script parameters.
  • ConfigureAzureApplication.ps1 removal has a typo — the "already absent" short-circuit on the remove branch checks $App.web.redirectUrs (missing an i), so it never matches and the removal logic runs unconditionally. It still removes the URI correctly via the filtered redirectUris assignment, but the early-exit is dead.
  • Two dashboard group GUIDs differ between the deploy and teardown scripts (see the warning under Tear down).
  • Hardcoded IDs/secrets in source — the AAD login app id d9422a95-…, the dashboard group GUIDs, the static dbPassword "securePassword-2026!" and InternalServicePassword (Program.cs) are checked in. This path does not use Azure Key Vault; CI secrets come from GitHub Actions and config from the deployed JSON layers (Deployment Configuration). Treat the checked-in values as non-production-grade.
  • cleanup-dns.yml.inactive is disabled — the standalone daily DNS sweep does not run; only the teardown path cleans DNS.

See also