Azure App Service Deployment¶
Azure App Service (Web Apps) is the primary production deployment path for Cargonerds.
The five long-lived services run as Linux Web Apps with deployment slots, fed by two
PowerShell scripts — PublishApp.ps1 (build + zip) and DeployToAppServices.ps1 (slot-aware
zip-deploy + DB-migration handshake) — and driven by two GitHub Actions workflows.
This is a separate path from the Aspire / Azure Container Apps deployment described in
Azure Container Apps. Despite the Aspire.Hosting.Azure.AppService
package being referenced by the AppHost, App Service is never deployed via Aspire — it is
reached only through the zip/slot pipeline documented here. See the
Deployment overview for the "two paths" big picture.
What lives where
All scripts referenced on this page are under
C:/dev/Cargonerds/github/CargonerdsApp/.scripts/. The two workflows are under
C:/dev/Cargonerds/github/CargonerdsApp/.github/workflows/. The version source is
C:/dev/Cargonerds/github/CargonerdsApp/common.props.
Concept: slots, environments, and the four-service shape¶
Each App Service runs one host of the solution. There are five hosts packaged by the pipeline — Authenticator (AuthServer), Api (HttpApi.Host), Admin (Blazor), Realtime (Next.js), and the DbMigrator — but the DbMigrator is not a user-facing app: it is started only for the duration of a migration and then stopped again (see DB-migration handshake).
A deployment slot is a parallel copy of an App Service with its own hostname and app settings. Cargonerds uses slots both to provide logical environments on a single set of App Services and to implement warm-up + swap zero-downtime releases:
- New code is deployed into a non-production slot.
- The slot is warmed up under the production slot's app settings (
swap --action preview). - Once the slot is healthy, a swap flips it into the production slot atomically.
App Service / slot naming¶
Names are computed entirely from the -AzureEnvironment parameter in
DeployToAppServices.ps1. There are two
physical App Service groups — dev and prod — each hosting the four services plus the
migrator:
-AzureEnvironment |
Resource group | App-name prefix | Slot |
|---|---|---|---|
Dev-HubTest |
rg-spark-dev |
dev |
hub-test |
Dev-HubProd |
rg-spark-dev |
dev |
hub-prod |
Prod-Staging |
rg-spark-prod |
prod |
staging |
The App Service name for a service is spark-<prefix>-<service>, and the slot name is
spark-<prefix>-<slot>-<service>. So for Dev-HubTest the API app is spark-dev-api and its
target slot is spark-dev-hub-test-api; for Prod-Staging the API app is spark-prod-api and
the slot is spark-prod-staging-api.
# DeployToAppServices.ps1 — environment → RG / prefix / slot
$resourceGroup = IF ($AzureEnvironment -eq "Prod-Staging") {"rg-spark-prod"} else {"rg-spark-dev"}
$slot = "";
if ($AzureEnvironment -eq "Prod-Staging") { $slot = "staging" }
elseif ($AzureEnvironment -eq "Dev-HubProd") { $slot = "hub-prod" }
elseif ($AzureEnvironment -eq "Dev-HubTest") { $slot = "hub-test" }
$appPrefix = if($AzureEnvironment -eq "Prod-Staging") {"prod"} else {"dev"}
# Slot name is service-specific
function GetSlotName($serviceName) {
if ($slot -ne "") {
return "spark-$appPrefix-$slot-$serviceName"
}
return ""
}
Dev-HubTest and Dev-HubProd are not separate App Services
They are two slots (hub-test, hub-prod) on the same spark-dev-* App Services in
rg-spark-dev. What differs between them is the per-slot app settings — primarily
AZURE_ENVIRONMENT (and through the azure overlay files, SPARK_ENVIRONMENT), which select
the database and Hub data source. See Configuration and the
appsettings reference for how AZURE_ENVIRONMENT /
SPARK_ENVIRONMENT drive the config layering.
End-to-end flow¶
The diagram below shows the Dev App Service pipeline (push to main). The release pipeline
is structurally similar but targets the staging slot of prod and adds a manual approval gate
(see Release pipeline).
flowchart TD
A[push to main] --> B[update job: UpdateVersion.ps1 -incVersion build -commit]
B --> C[verify bumped SHA visible on origin]
C --> D[deploy job: checkout exact ref]
D --> E[PublishApp.ps1 - build & zip 5 artifacts]
E --> F[Deploy Dev-HubTest with migration]
F --> G[Deploy Dev-HubProd with migration]
G --> H[SwapAppServiceSlots.ps1 - hub-test into production]
H --> I[Redeploy Dev-HubTest -SkipDbMigration]
subgraph deploy_one[DeployToAppServices.ps1 per environment]
direction TB
M1[migrateDb - DbMigrator slot handshake] --> M2[deploy Authenticator]
M2 --> M3[deploy Api]
M3 --> M4[deploy Realtime]
M4 --> M5[deploy Admin]
end
PublishApp.ps1 — build and package¶
PublishApp.ps1 produces the zip artifacts consumed by the deploy script. It builds each .NET
host framework-dependent for linux-x64 in Release, then zips the publish output. The
Realtime Next.js app is built separately into a standalone bundle.
PowerShell 7 is required
The script header is #requires -version 7.0. The comment explains why: PowerShell 5's
Compress-Archive produces zip files that Azure App Service rejects. Run it with pwsh,
not Windows PowerShell — the CI steps use shell: pwsh.
What it does, in order:
cdto the repo root (git rev-parse --show-toplevel) and wipe any existing./publish.dotnet publish ... -c Release -r linux-x64each host into./publish/<Name>, thenCompress-Archivethe contents into a sibling<Name>.zip:Cargonerds.AuthServer→publish/Authenticator→Authenticator.zipCargonerds.HttpApi.Host→publish/Api→Api.zipCargonerds.DbMigrator→publish/DbMigrator→DbMigrator.zipCargonerds.Blazor→publish/Admin→Admin.zip- Realtime:
npm ciinfrontend/,npm run build --workspace=./realtime, then copy the Next.js.next/standalone/realtime,node_modules, and.next/staticintopublish/Realtime, normalize file timestamps to UTC, and zip viaSystem.IO.Compression.ZipFile.CreateFromDirectory(notCompress-Archive).
Write-Host "Publishing Api" -ForegroundColor Cyan
dotnet publish $repoRoot/src/Cargonerds.HttpApi.Host/Cargonerds.HttpApi.Host.csproj -c Release -o $repoRoot/publish/Api -r linux-x64
ExitIfLastExitCodeNotZero "Publishing Api"
Set-Location -Path $repoRoot/publish/Api
Compress-Archive -Force -DestinationPath $repoRoot/publish/Api$version.zip -Path *
# Realtime: standalone Next.js bundle, timestamps normalized, then ZipFile (not Compress-Archive)
$utcNow = [DateTime]::UtcNow
Get-ChildItem -Path . -Recurse -File | Where-Object { $_.CreationTime -gt $utcNow} | ForEach-Object {
$_.LastWriteTime = $utcNow
$_.CreationTime = $utcNow
}
[System.IO.Compression.ZipFile]::CreateFromDirectory((Resolve-Path "Realtime").Path, (Join-Path $PWD "Realtime.zip"))
Optional -version argument
PublishApp.ps1 accepts an optional -version parameter that, when set, appends -v<version>
to the .NET zip names. The CI workflows call it without a version, so the deploy script
can rely on the plain Authenticator.zip / Api.zip / DbMigrator.zip / Admin.zip /
Realtime.zip names. (Realtime.zip is always unversioned because it is produced via the
ZipFile call, which ignores $version.)
Because the .NET apps are published framework-dependent, they run on the App Service's
configured .NET runtime — unlike the container images used by the ACA/Helm paths, which still pin
aspnet:9.0. See the Deployment overview for the runtime-version caveat.
DeployToAppServices.ps1 — slot-aware zip-deploy¶
This script takes one -AzureEnvironment (one of Prod-Staging, Dev-HubProd, Dev-HubTest),
optionally -SkipDbMigration and -MaxRetryCount (default 3). It dot-sources
MigrateAppServiceDb.ps1, runs the DB migration first (unless skipped), then deploys the four
user-facing services in a fixed order: Authenticator → Api → Realtime → Admin.
param (
[ValidateSet("Prod-Staging","Dev-HubProd","Dev-HubTest")]
[Parameter(Mandatory = $true)]
[string]$AzureEnvironment,
[switch]$SkipDbMigration,
[int] $MaxRetryCount = 3
)
Each service deploy uses az webapp deploy with --type zip --track-status false --clean true
against the computed slot, with retries:
az webapp deploy `
--src-path $zipPath `
-g $resourceGroup `
-n $appServiceName `
-s $slotName `
--type zip `
--track-status false `
--clean true
The "CLI said fail but it actually succeeded" guard¶
az webapp deploy frequently reports failure even when the deployment succeeded; that is exactly
why --track-status false is set. When the CLI returns a non-zero exit code, the script does
not immediately fail — it calls VerifyDeploymentFromScmLogs (from MigrateAppServiceDb.ps1)
to ask the Kudu SCM site for the real status before deciding whether to retry.
VerifyDeploymentFromScmLogs acquires a management access token (az account get-access-token),
polls https://<app>[-<slot>].scm.azurewebsites.net/api/deployments?$top=1, maps the numeric SCM
status (0 Pending, 1 Building, 2 Deploying, 3 Failed, 4 Success), and returns true
only on 4. If SCM confirms success, the script forces $global:LASTEXITCODE = 0 and continues.
After each successful deploy, WaitForHttp200 polls https://<app>[-<slot>].azurewebsites.net/
(up to 900 s, 10 s interval) so the next step only proceeds once the slot is actually serving.
$scmSuccess = VerifyDeploymentFromScmLogs -appServiceName $appServiceName -slotName $slotName -deployStartTime $deployStartTime
if ($scmSuccess) {
Write-Host "SCM logs confirm $displayName deployment succeeded despite az CLI reporting failure." -ForegroundColor Green
$global:LASTEXITCODE = 0
break;
}
Why the SCM fallback matters
Without VerifyDeploymentFromScmLogs, the flaky CLI exit code would fail otherwise-green
deployments and exhaust the retry budget. This verification step is what keeps the pipeline
reliable. The same retry-then-verify pattern is reused for the DbMigrator deploy.
DB-migration handshake¶
Database migrations are an explicit, gated step run by a dedicated DbMigrator App Service
slot — never a startup side-effect of the web apps. The orchestration lives in
MigrateAppServiceDb.ps1::migrateDb, called by DeployToAppServices.ps1 (and again, in
deploy-skipping mode, by the swap script — see Slot swap).
The DbMigrator host (src/Cargonerds.DbMigrator/Program.cs) exposes a single minimal endpoint
only when started with --enable-api, guarded by a one-time token:
app.MapGet(
"/migration/status",
(MigrationStateManger stateManager, IConfiguration configuration, HttpContext context) =>
{
var configToken = configuration.GetValue<string?>("PIPELINE_AUTH_TOKEN");
if (string.IsNullOrEmpty(configToken))
return Results.Problem("Authentication not configured", statusCode: 503);
var queryToken = context.Request.Query["token"].FirstOrDefault();
if (queryToken != configToken)
return Results.Unauthorized();
return Results.Ok(new { state = stateManager.State.ToString(), exception = /* … */ });
});
The state value comes from MigrationStateManger (note the spelling) and is one of
NotStarted, InProgress, Completed, Failed.
migrateDb performs the following handshake:
- Refuse to proceed unless the migrator slot is
Stopped(az webapp show). A running migrator means a migration is in flight, so the script exits rather than risk a concurrent run. - Generate a random 50-char token and set it as the
PIPELINE_AUTH_TOKENapp setting on the migrator slot. - Deploy
DbMigrator.zip(same retry + SCM-verify logic), thenaz webapp startthe slot. - Poll
https://<migrator>/migration/status?token=<token>untilstate == Completed(Failedaborts with the returned exception message). - In a
finallyblock on every poll iteration, stop the slot and delete thePIPELINE_AUTH_TOKENapp setting — keeping the token short-lived and ensuring the migrator is left stopped.
if ($dbMigratorProperties.state -ne "Stopped")
{
Write-Host "DbMigrator is currently running. ... Please wait for it to complete ..." -ForegroundColor Red
exit 1
}
$authToken = GenerateAuthToken # random 50-char string
az webapp config appsettings set -g $resourceGroup -n $appPrefix-migrator -s $dbMigratorSlotName `
--settings PIPELINE_AUTH_TOKEN=$authToken
finally {
az webapp stop -g $resourceGroup -s $dbMigratorSlotName -n $appPrefix-migrator
az webapp config appsettings delete -g $resourceGroup -n $appPrefix-migrator `
-s $dbMigratorSlotName --setting-names PIPELINE_AUTH_TOKEN
}
Optional AZURE_ENVIRONMENT override
migrateDb accepts an optional azureEnvironmentOverride. When supplied (used by the swap
path so the migration runs against the production-slot DB), it reads and remembers the
slot's current AZURE_ENVIRONMENT, overrides it, and reverts it in finally. The
migrator also uses DB-admin credentials via its own appsettings.azure.migrator.<env>.json
overlay, distinct from the apps' runtime credentials — see
Database Migrations and
Configuration.
Slot swap (SwapAppServiceSlots.ps1)¶
SwapAppServiceSlots.ps1 performs the zero-downtime promotion of a non-production slot into the
production slot. It accepts -AzureEnvironment of Prod-Staging (staging → prod) or
Dev-HubTest (hub-test → prod).
if ($AzureEnvironment -eq "Prod-Staging") {
$resourceGroup = "rg-spark-prod"; $appBasePrefix = "spark-prod"; $slotName = "staging"
} else {
$resourceGroup = "rg-spark-dev"; $appBasePrefix = "spark-dev"; $slotName = "hub-test"
}
Sequence:
- Migrate against the production-slot DB first. It reads the migrator's
AZURE_ENVIRONMENTapp setting and callsmigrateDbwithskipDeployment = $trueplus that value as the override, so the production database is migrated before any traffic shift (the DbMigrator zip is not re-deployed here — only run). swap --action previewfor all four services (auth,api,realtime,admin) — this warms each slot up under the production app settings without shifting traffic.-
Health-poll each service until all return HTTP 200, using service-specific endpoints:
Service Health endpoint auth/.well-known/openid-configurationapi/api/abp/application-configurationrealtime/admin/ -
swap --action swapfor all four services to complete the promotion.
The health base URL is chosen from the slot's hostnames with a priority order:
*.rohlig.com > *.cargonerds.dev > any other custom domain > *.azurewebsites.net.
# Prefer custom domains with priority: .rohlig.com > .cargonerds.dev > others > .azurewebsites.net
$domain = $hostnames | Where-Object { $_ -like "*.rohlig.com" } | Select-Object -First 1
if (-not $domain) { $domain = $hostnames | Where-Object { $_ -like "*.cargonerds.dev" } | Select-Object -First 1 }
if (-not $domain) { $domain = $hostnames | Where-Object { $_ -notlike "*.azurewebsites.net" } | Select-Object -First 1 }
if (-not $domain) { $domain = $hostnames | Select-Object -First 1 }
Rollback of a stuck preview
The two phases of a slot swap (preview then swap) mean a slot can be left in preview
state if the script errors between them. The catch block prints
az webapp deployment slot swap ... --action reset commands (one line per service) to cancel
the preview so an operator can roll back manually. Treat these as a starting point, not
copy-paste-exact: the printed lines reuse the loop's last $serviceSlotName for every
service and omit --target-slot, so substitute the correct per-service slot
(spark-<prefix>-<slot>-<service>) before running each one.
DNS and TLS automation¶
The swap health-poll above selects its base URL from a slot's custom hostnames (*.rohlig.com >
*.cargonerds.dev > …), which means those hostnames — and their certificates — must already be
bound to the slots. Binding is not done by the deploy/swap scripts; it is a separate, idempotent
setup step performed by SetupAppServiceCustomDomains.ps1.
SetupAppServiceCustomDomains.ps1 — DNS + slot TLS¶
Run on demand against one resource group (-resourceGroup rg-spark-dev or rg-spark-prod). It
discovers the Azure DNS zones (Get-AzDnsZone) and App Services (Get-AzWebApp) in that group and,
for every custom hostname (anything that is not the default *.azurewebsites.net host),
reconciles DNS and certificates:
For each App Service and each of its deployment slots:
- A records.
Set-DnsARecordForCustomDomainresolves the app/slot's default host to an IP and creates or updates anArecord (TTL 3600) for the custom hostname in the matching DNS zone, skipping it if the record already points at the right IP. -
Slot TLS.
Ensure-SlotManagedCertificateAndBindingcreates an App Service managed certificate for the slot hostname and binds it with SNI:
It is idempotent: an existing SNI binding (or an existing managed cert matching the hostname /
thumbprint) is reused via New-AzWebAppSSLBinding instead of re-issued.
What the script does not do
- Main app-service hostnames get DNS but no certificate. SSL provisioning is intentionally
skipped for the apps themselves (
"SSL certificate setup is intentionally skipped for main app service hostnames"); managed certs and bindings are created only on the deployment slots — which is exactly where the swap health-poll connects. - Wildcards are skipped. Managed certificates do not support
*hostnames, so a wildcard custom domain is logged and passed over (bind such a cert manually). - It needs the
Azmodules. IfGet-AzWebAppCertificate/New-AzWebAppCertificate/*-AzWebAppSSLBindingare not installed, certificate/binding management is skipped with a warning and only the DNS A-records are reconciled.
Custom domains must be added first
The script only reconciles hostnames that are already added to the app/slot (it reads
appDetails.HostNames / slotDetails.HostNames). Adding a brand-new custom domain to a slot
(the az webapp config hostname add step) is a prerequisite it does not perform.
Rollback and recovery¶
App Service is the primary production path, but the pipeline is forward-oriented — the scripts automate deploy/migrate/swap, not undo. Recovery is largely manual; the table below maps the common failure points to the right response.
| Situation | What happened | Recovery |
|---|---|---|
Script errored between preview and swap |
A slot is stuck in Swap Preview | Run the printed ... --action reset commands (corrected per-service — see the warning under Slot swap). No traffic shifted yet. |
| Swap completed but the new code is bad | Bad content is now in the production slot, old content in the source slot | Swap back: re-run az webapp deployment slot swap ... --target-slot production --action swap for each service to put the previous content back into production. The swap script has no "swap back" mode, so do it per service. |
WaitForHttp200 / swap health-poll times out |
A slot never returned HTTP 200, so the swap was not completed (or a deploy step failed) | No traffic shifted. Investigate the slot (logs / Kudu) and re-run the step once healthy; the deploy and migration steps are idempotent. |
| DB migration failed mid-run | The migrator reported Failed; the swap/deploy aborts |
Migrations are applied forward by the DbMigrator and the pipeline has no down-migration step. Fix forward: resolve the cause and re-run the migrator (the slot is left Stopped and PIPELINE_AUTH_TOKEN deleted by the finally block, so the next run starts clean). A code swap-back does not revert schema changes. |
| Migrator left running after an aborted run | The "migrator must be Stopped" guard blocks the next deploy |
az webapp stop the migrator slot, confirm no migration is actually in flight, then re-run. |
Schema and code roll back independently
Swapping the code back to a previous slot does not undo a migration that already ran against the production database. If a release both migrated the schema and shipped bad code, plan the schema rollback separately (a corrective forward migration) — there are no automated down-migrations in this pipeline. See Database Migrations.
Dev App Service pipeline¶
Workflow: .github/workflows/azure-dev-app-services.yml — Deploy Dev App Services, triggered
on push to main (and workflow_dispatch). It uses the GitHub stage environment and runs two
jobs.
update job (Windows) — version bump¶
Bumps the 4th (build) component of <Version> in common.props and commits + pushes the
change, using a GIT_ADMIN_TOKEN:
- name: Update version
run: powershell -ExecutionPolicy Bypass -File '.scripts/UpdateVersion.ps1' -incVersion build -commit
UpdateVersion.ps1 reads common.props, increments the requested component
(major/minor/patch/build, padding to four parts), rewrites the file as UTF-8 without
BOM, then git commit -m "chore: bump version to <v> (common.props)" and
git push origin HEAD:<targetBranch>. The current version is 3.1.4.0 (see common.props):
<!-- common.props -->
<Version>3.1.4.0</Version>
<AssemblyVersion>$(Version)</AssemblyVersion>
<SourceRevisionId>build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))</SourceRevisionId>
Self-bump rerun guard
The push of the bump commit would itself trigger another push to main. The workflow's
update job is guarded so it skips the commit it just created:
After bumping, a Verify bumped commit on origin step retries up to 5 times until
git ls-remote origin refs/heads/<branch> reports the pushed SHA, then exports it as the ref
output. This guarantees the deploy job checks out the exact bumped commit.
deploy job (Ubuntu) — publish, deploy, swap¶
Checks out needs.update.outputs.ref, sets up .NET 10.x.x, runs dotnet workload restore,
installs the ABP CLI and runs abp install-libs (restores client-side libraries), then
logs in to Azure via OIDC (azure/login@v2). The deployment steps are:
- name: Publish Code
run: ./PublishApp.ps1
- name: Deploy To Dev-HubTest
run: ./DeployToAppServices.ps1 -AzureEnvironment Dev-HubTest
- name: Deploy To Dev-HubProd
run: ./DeployToAppServices.ps1 -AzureEnvironment Dev-HubProd
- name: Swap Environments for HubTest to production slot
run: ./SwapAppServiceSlots.ps1 -AzureEnvironment Dev-HubTest
- name: Deploy To Dev-HubTest after swap with production slot
run: ./DeployToAppServices.ps1 -AzureEnvironment Dev-HubTest -SkipDbMigration
Why HubTest is redeployed after the swap
The swap moves the freshly deployed hub-test slot content into the production slot,
which leaves the hub-test slot holding the old production content. The final step
redeploys Dev-HubTest to refill it. It passes -SkipDbMigration because the migration
already ran (during the swap, against the production DB) — re-running it would be wasteful and
could re-trigger the "migrator must be stopped" guard.
Release pipeline¶
Workflow: .github/workflows/release-deploy-app-services.yml — Deploy Release App Services,
triggered on release: [published]. It deploys both paths and adds a manual approval gate.
| Job | Environment | What it does |
|---|---|---|
update |
stage |
UpdateVersion.ps1 -version <release tag_name> -commit — sets the exact release version (not an increment). |
deployAspire |
stage |
DeployToAzureContainerApps.ps1 -EnvironmentName 'stage' -SkipEnvSetup $true -SparkEnvironment 'Test' — the ACA stage.cargonerds.dev deploy (see Azure Container Apps). |
deployAppService |
stage |
PublishApp.ps1 then DeployToAppServices.ps1 -AzureEnvironment Prod-Staging — deploys into the prod staging slot. |
approve |
production |
A no-op step gated by the GitHub production environment — i.e. manual approval. |
swap |
stage |
SwapAppServiceSlots.ps1 -AzureEnvironment Prod-Staging — promotes staging → production. |
So a release first lands in the production staging slot (live, but not serving production
traffic), waits for a human to approve the production environment, and only then swaps into the
live production slot.
environment: stage vs Azure environments
In both workflows, environment: name: stage / production refer to GitHub deployment
environments (used for secrets and approval gates), not to the script's -AzureEnvironment
values or the AZURE_ENVIRONMENT app setting. The release YAML even annotates this:
name: stage #env name for github env not the azd env.
App settings and configuration¶
App Service slots are configured almost entirely through slot-level app settings, with
AZURE_ENVIRONMENT as the key switch. At startup each host runs AddServiceDefaults(), which
layers appsettings.azure[.<part>...].json files and a token-replacement pass based on
AZURE_ENVIRONMENT; the azure overlay files in turn set SPARK_ENVIRONMENT, which selects the
Hub database / blob storage / service bus. The dotted AZURE_ENVIRONMENT value
(e.g. dev.hub-test, prod.staging) is what maps a slot to its overlay file.
The two app settings the pipeline writes at runtime are:
PIPELINE_AUTH_TOKEN— set on the migrator slot during a migration and deleted afterwards.AZURE_ENVIRONMENT— temporarily overridden on the migrator slot during a swap (and reverted) so the migration targets the production-slot DB.
For the full layering pipeline, the two token engines, and which file supplies which connection string, see Configuration and the appsettings reference.
Gotchas¶
Operational pitfalls
- PowerShell 7 only for
PublishApp.ps1. PS5'sCompress-Archiveyields zips Azure App Service refuses; the script enforces#requires -version 7.0. CI usesshell: pwsh. az webapp deploylies about success.--track-status falseplus theVerifyDeploymentFromScmLogsKudu check exist precisely because the CLI reports failure on successful deploys. Don't treat a non-zero exit as authoritative — check the SCM status.- The migrator must be
Stoppedbefore a migration.migrateDbexits if the migrator slot is running. A leftover running migrator (e.g. from an aborted run) blocks the pipeline until it is stopped. - HubTest gets redeployed with
-SkipDbMigrationafter the swap. The swap moved its content to production, so the final redeploy must not re-run the migration. - Self-bump loop. The dev workflow must skip the
chore: bump version to …push it creates, or it would deploy on its own commit forever. - Framework-dependent publish. The .NET zips run on the App Service runtime, not the
aspnet:9.0images used by the container paths — keep the App Service runtime version in mind separately from the Dockerfiles (see Overview). - GitHub
environment≠AZURE_ENVIRONMENT.stage/productionare GitHub environments for approvals/secrets, unrelated to the-AzureEnvironmentparameter and theAZURE_ENVIRONMENTapp setting.
Related ABP patterns¶
The App Service path leans on a handful of ABP framework features:
- ABP CLI
abp install-libsruns before every App Service build to restore client-side libraries (NPM/Bower assets) — part of ABP's bundling & minification workflow. - DbMigrator as a deployable service. ABP's
DbMigratorhost runs migrations and data seeding; here it is packaged as its own App Service slot and driven through the/migration/statushandshake. See Database Migrations. - Health endpoints used for warm-up. The swap uses ABP's
application-configuration
endpoint (
/api/abp/application-configuration) for the API and the OpenIddict discovery document (/.well-known/openid-configuration) for the AuthServer as readiness probes.
See also¶
- Deployment overview — the "two paths" (Aspire/ACA vs App Service) and shared facts.
- Azure Container Apps — the Aspire/
azdephemeral +stagepath. - CI/CD Pipelines — per-workflow reference, OIDC auth, and GitHub environments.
- Configuration —
AZURE_ENVIRONMENT/SPARK_ENVIRONMENT, the azure overlay chain, and the migrator's admin credentials. - Database Migrations — the DbMigrator host and migration model.
- .NET Aspire Integration — why App Service is not deployed via Aspire even though the package is referenced.
SetupAppServiceCustomDomains.ps1— the on-demand DNS A-record and slot managed-certificate setup the swap health-poll depends on.- appsettings reference — the concrete keys and overlay files.