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:
# 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 usesactions/setup-dotnet10.x.x), Docker, Node.js 22 (for therealtimeNext.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.devzone (held in resource grouprg-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_Thing → feat-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:
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 backend — Default/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-<env><br/>rg = rg-ABP-<env><br/>domain = <env>.cargonerds.dev"]
derive --> conc["AZD_UP_CONCURRENCY=1<br/>DEPLOYMENT_DOMAIN=<domain>"]
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;
- Resolve the environment name (
-EnvironmentName, the interactive menu, or$CARGONERDS_APP_ENV_NAME) and computeazdEnvName,resourceGroupandfullDomain. - Serialise the deploy and export the domain. It sets
AZD_UP_CONCURRENCY=1andDEPLOYMENT_DOMAIN=<env>.cargonerds.devbefore provisioning. - Create/select the azd environment in
germanywestcentralif it does not exist, thenazd 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. - Post-deploy (unless
-SkipEnvSetup) — DNS + TLS, dashboard RBAC, AD redirect URI. - Print the resulting URLs, e.g.
https://api.<env>.cargonerds.dev,https://auth.<env>.cargonerds.dev.
$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:
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:
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:
- reads the environment's
customDomainVerificationId(the ASUID) and the app's ingress FQDN (DefaultDomain); - writes a TXT record
asuid.<app>.<env>= that ASUID, for domain ownership validation; - writes a CNAME
<app>.<env>.cargonerds.dev→ the app FQDN (--ttl 60); - adds the hostname to the app (
az containerapp hostname add); - binds it and provisions a managed certificate with
--validation-method CNAME; - removes the validation TXT record.
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:
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:
$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.ps1grants 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-RoleisContributorand it assigns the role to each member of the group individually (recursing into nested groups).ConfigureAzureApplication.ps1 -Action addregistershttps://auth.<env>.cargonerds.dev/signin-oidcas a redirect URI on the shared AAD login appd9422a95-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-<env> live<br/><env>.cargonerds.dev"]
live --> cron["teardown.yml<br/>daily 0 2 * * *"]
cron --> check{"autoTeardownEnabled<br/>&& age >= max_age_days?"}
check -- yes --> del["az group delete<br/>+ purge soft-deleted Key Vaults"]
check -- no --> live
- Tagging — the
configurejob setsdeployedAt,triggeredBy,branch,isProtectedBranch,isManualDeployment,autoTeardownEnabled,commitShaandworkflowRunIdon the RG.autoTeardownEnabled=trueonly for a manual deploy of a non-protected branch. - Protected branches —
main,productionandstagingare protected: they are never auto-torn-down and cannot be supplied as a manual override environment name. - Reaping —
teardown.yml(daily cron0 2 * * *+ manual) listsrg-ABP-*, skips any RG withautoTeardownEnabled != true, deletes those older thanmax_age_days(default 7), and purges soft-deleted Key Vaults so their names can be reused. (Manual teardown requires typingDELETE.)
See the CI/CD Pipelines page for the full per-workflow reference.
Tear down¶
TeardownAzureContainerApps.ps1 is the local equivalent of the scheduled reaper:
azd down --environment ABP-<env> --force— deletes all provisioned Azure resources.CleanupDnsEntries.ps1— removes dangling CNAMEs in the zone (only untagged records whose target no longer resolves viaResolve-DnsName; tagged records are always skipped).ConfigureAzureApplication.ps1 -Action remove— removes thesignin-oidcredirect 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"]
-
resolve-environment— sanitises the pushed branch (or theworkflow_dispatchoverride) intoenv_name, and computesis_protected_branch/is_manual_deployment. A manual override matchingPROTECTED_BRANCHES="main|production|staging"is rejected: -
deploy(GitHub environmentstage) — installsazd+ .NET 10 + the ABP CLI (abp install-libs), logs in to Azure via OIDC, runsazd 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' }}' -
configure(GitHub environmentstage) — runsSetupDnsAndCertificates.ps1againstrg-ABP-<env>(becausedeployskipped 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:
./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--locationforazd env new). Change it in the script if you provision elsewhere. - DNS zone / resource group — change the script parameters (
-DnsZoneName,-DnsResourceGroup) or the$dnsZoneName/$dnsResourceGroupvariables inSetupDnsAndCertificates.ps1. - Spark backend —
-SparkEnvironment Testpoints 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=1is mandatory — a bareazd upoutside the wrapper can fail from colliding parallel publishes (regression sinceazure-dev-cli1.25.0).DEPLOYMENT_DOMAINmust be set in publish mode — the AppHost throws otherwise. The wrapper exports it; a bareazd upwithout it fails.-SkipEnvSetupskips post-deploy steps, not the azd bootstrap — see the warning under Script parameters.ConfigureAzureApplication.ps1removal has a typo — the "already absent" short-circuit on theremovebranch checks$App.web.redirectUrs(missing ani), so it never matches and the removal logic runs unconditionally. It still removes the URI correctly via the filteredredirectUrisassignment, 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 staticdbPassword "securePassword-2026!"andInternalServicePassword(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.inactiveis disabled — the standalone daily DNS sweep does not run; only the teardown path cleans DNS.
See also¶
- Deployment Overview — both Azure paths side by side
- Azure App Service — the production publish + slot-swap path
- CI/CD Pipelines — per-workflow reference, OIDC, environment gates
- Local Deployment — run the full Aspire stack on your machine
- Deployment Configuration and the Configuration Reference — how per-environment values are composed
- .NET Aspire Integration — the AppHost resource graph