Skip to content

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:

  1. cd to the repo root (git rev-parse --show-toplevel) and wipe any existing ./publish.
  2. dotnet publish ... -c Release -r linux-x64 each host into ./publish/<Name>, then Compress-Archive the contents into a sibling <Name>.zip:
  3. Cargonerds.AuthServerpublish/AuthenticatorAuthenticator.zip
  4. Cargonerds.HttpApi.Hostpublish/ApiApi.zip
  5. Cargonerds.DbMigratorpublish/DbMigratorDbMigrator.zip
  6. Cargonerds.Blazorpublish/AdminAdmin.zip
  7. Realtime: npm ci in frontend/, npm run build --workspace=./realtime, then copy the Next.js .next/standalone/realtime, node_modules, and .next/static into publish/Realtime, normalize file timestamps to UTC, and zip via System.IO.Compression.ZipFile.CreateFromDirectory (not Compress-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:

  1. 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.
  2. Generate a random 50-char token and set it as the PIPELINE_AUTH_TOKEN app setting on the migrator slot.
  3. Deploy DbMigrator.zip (same retry + SCM-verify logic), then az webapp start the slot.
  4. Poll https://<migrator>/migration/status?token=<token> until state == Completed (Failed aborts with the returned exception message).
  5. In a finally block on every poll iteration, stop the slot and delete the PIPELINE_AUTH_TOKEN app 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:

  1. Migrate against the production-slot DB first. It reads the migrator's AZURE_ENVIRONMENT app setting and calls migrateDb with skipDeployment = $true plus 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).
  2. swap --action preview for all four services (auth, api, realtime, admin) — this warms each slot up under the production app settings without shifting traffic.
  3. Health-poll each service until all return HTTP 200, using service-specific endpoints:

    Service Health endpoint
    auth /.well-known/openid-configuration
    api /api/abp/application-configuration
    realtime /
    admin /
  4. swap --action swap for 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:

param (
    [Parameter(Mandatory = $true)]
    [string]$resourceGroup
)

For each App Service and each of its deployment slots:

  1. A records. Set-DnsARecordForCustomDomain resolves the app/slot's default host to an IP and creates or updates an A record (TTL 3600) for the custom hostname in the matching DNS zone, skipping it if the record already points at the right IP.
  2. Slot TLS. Ensure-SlotManagedCertificateAndBinding creates an App Service managed certificate for the slot hostname and binds it with SNI:

    New-AzWebAppCertificate -ResourceGroupName $resourceGroup -WebAppName $app.Name `
        -Slot $slotName -Name $certificateName -HostName $hostname -AddBinding -SslState SniEnabled
    

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 Az modules. If Get-AzWebAppCertificate / New-AzWebAppCertificate / *-AzWebAppSSLBinding are 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.ymlDeploy 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:

if: >-
  github.event_name != 'push' ||
  !startsWith(github.event.head_commit.message, 'chore: bump version to ')

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.ymlDeploy 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's Compress-Archive yields zips Azure App Service refuses; the script enforces #requires -version 7.0. CI uses shell: pwsh.
  • az webapp deploy lies about success. --track-status false plus the VerifyDeploymentFromScmLogs Kudu 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 Stopped before a migration. migrateDb exits 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 -SkipDbMigration after 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.0 images used by the container paths — keep the App Service runtime version in mind separately from the Dockerfiles (see Overview).
  • GitHub environmentAZURE_ENVIRONMENT. stage/production are GitHub environments for approvals/secrets, unrelated to the -AzureEnvironment parameter and the AZURE_ENVIRONMENT app setting.

The App Service path leans on a handful of ABP framework features:

  • ABP CLI abp install-libs runs 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 DbMigrator host runs migrations and data seeding; here it is packaged as its own App Service slot and driven through the /migration/status handshake. 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