Hands-on Labs · Hands-on lab

Azure App Service Multistack Deployment

Deploy the Python, Node.js, and .NET reference applications to their Terraform-provisioned Azure App Services with GitHub Actions and Azure DevOps.

Document class: Hands-on Labs guided implementation lab Normative terms: MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are requirements levels. Applicability: Terraform and pipeline delivery for Python, Node.js, .NET, and containerized workloads on Azure App Service, including identity, configuration, networking, slots, rollback, and drift. Exception process: Deviations require a documented risk assessment, compensating controls, named risk owner, and expiry date.

Control fieldValue
Document IDHOL-03
OwnerCloud Center of Excellence
Review cycleAt least annually and after material App Service, provider, security, or source-repository changes
EvidenceReviewed repository snapshots, Terraform plan and apply, build artifacts, application health, identity and configuration checks, rollback tests, and cleanup evidence

Decision in brief: Build and deploy the same identified release across Python, Node.js, and .NET paths, keeping Terraform, pipeline, identity, configuration, and rollback boundaries explicit.

This consolidated guide is both a deployment standard and a hands-on lab. It uses four public reference repositories: one Terraform landing zone that provisions three App Services and three applications implemented with Python, Node.js, and .NET. The application repositories contain working GitHub Actions and Azure DevOps pipelines; their checked-in automation is the executable source of truth.

The current development estate uses F1 App Service plans. F1 supports direct deployment but not deployment slots. Complete the direct-deployment exercises on the existing resources. The slot-promotion pattern applies after the plan is changed through Terraform to S1 or another tier that supports slots.

Outcomes

After completing the lab, you will be able to:

  • trace each application from Terraform configuration to runtime and pipeline;
  • compare ZIP deployment, run from package, Oryx build automation, Deployment Center, and custom containers;
  • package Flask/Python 3.12, Express/Node.js 24, and ASP.NET Core .NET 8 correctly;
  • run and evaluate both GitHub Actions and Azure DevOps delivery paths;
  • manage Deployment Center conflicts, SCM access restrictions, health validation, and rollback;
  • separate pipeline identity, runtime identity, configuration, and secrets;
  • extend direct deployment to slot-based promotion without rebuilding the release.

Governing principles

  1. Build once and deploy the same identified artifact. Do not rebuild between environments.
  2. Keep environment configuration and secrets outside the ZIP or container image.
  3. Separate deployment identity from the App Service runtime managed identity.
  4. Prefer workload identity federation and short-lived tokens over client secrets and publish profiles.
  5. Protect environments with branch controls, approvals, checks, and least-privilege RBAC.
  6. Make health, version, security, and rollback validation executable.
  7. Manage persistent App Service configuration in Terraform. Pipeline changes must not create unreviewed drift.
  8. Use only one authoritative deployment mechanism for a target at a time.

Reference repositories and reviewed snapshots

Public repositoryReviewed commitPurpose
andyxuan2010/azure-landingzone1cdf5caProvisions App Service plans, web apps, runtime settings, authentication, managed identity, health checks, networking, and deployment-method settings
andyxuan2010/web-ccoedemo-python71921e8Flask/Python 3.12 application with GitHub Actions, Oryx ZIP deployment, and an alternate self-contained package pipeline
andyxuan2010/web-ccoedemo-nodef16586cExpress/Node.js 24 application with self-contained production dependencies and ZIP/run-from-package delivery
andyxuan2010/web-ccoedemo-dotnet0e7d885ASP.NET Core .NET 8 application using tested dotnet publish output for Windows or Linux App Service

The snapshots make this guide reviewable. Before executing a newer commit, read its repository documentation and pipeline diff.

Provisioned topology

The landing-zone naming expression is web-${workload}-${location-code}-${environment}-${stack}. The development input uses workload platform, location canadacentral (cc), and environment dev.

ApplicationApp ServiceOS/runtimeHealthDevelopment method in Terraform
Pythonweb-platform-cc-dev-pythonLinux, Python 3.12, Gunicorn/healthDeployment Center, branch main
Node.jsweb-platform-cc-dev-nodeLinux, Node.js 24 LTS/healthDeployment Center, branch main
.NETweb-platform-cc-dev-dotnetWindows, .NET 8/healthDeployment Center, branch main

All three plans currently use F1 and 32-bit workers. The Terraform configuration enables system-assigned managed identity, authentication integration, site and SCM restrictions, and stack-specific settings.

flowchart LR LZ[azure-landingzone] --> PYAPP[Python App Service] LZ --> NODEAPP[Node App Service] LZ --> NETAPP[.NET App Service] PY[web-ccoedemo-python] -->|GitHub or ADO| PYAPP NODE[web-ccoedemo-node] -->|GitHub or ADO| NODEAPP NET[web-ccoedemo-dotnet] -->|GitHub or ADO| NETAPP PYAPP --> MON[Health, logs, metrics] NODEAPP --> MON NETAPP --> MON classDef infra fill:#15213b,stroke:#38d3ff,color:#e9eefc,stroke-width:2px; classDef app fill:#121d35,stroke:#3edbb0,color:#e9eefc,stroke-width:2px; classDef source fill:#121d35,stroke:#ffbd38,color:#e9eefc,stroke-width:2px; classDef observe fill:#0f1a30,stroke:#7181a6,color:#e9eefc,stroke-width:2px; class LZ infra; class PY,NODE,NET source; class PYAPP,NODEAPP,NETAPP app; class MON observe;

Terraform implementation

The following abbreviated input is taken from environments/dev/terraform.tfvars. It shows the runtime contract that application pipelines must preserve.

app_services = {
  dotnet = {
    enabled           = true
    stack             = "dotnet"
    kind              = "Windows"
    plan_os_type      = "Windows"
    sku_name          = "F1"
    use_32_bit_worker = true
    dotnet_version    = "v8.0"
    health_check_path = "/health"
    deployment_method = "deployment_center"
  }

  node = {
    enabled           = true
    stack             = "node"
    kind              = "Linux"
    plan_os_type      = "Linux"
    sku_name          = "F1"
    use_32_bit_worker = true
    node_version      = "24-lts"
    health_check_path = "/health"
    deployment_method = "deployment_center"
    app_settings = {
      WEBSITE_NODE_DEFAULT_VERSION = "~24"
    }
  }

  python = {
    enabled           = true
    stack             = "python"
    kind              = "Linux"
    plan_os_type      = "Linux"
    sku_name          = "F1"
    python_version    = "3.12"
    health_check_path = "/health"
    deployment_method = "deployment_center"
    startup_command   = "gunicorn --bind=0.0.0.0 --timeout 600 --access-logfile '-' --error-logfile '-' --chdir /home/site/wwwroot app:app"
  }
}

The resource loop in main.tf passes each selected runtime to the shared App Service module:

[!NOTE] The excerpts intentionally show the deployment contract, not the entire module. Follow the embedded source links for current validations, networking, diagnostics, authentication, and dependency behavior.

module "app_service" {
  for_each = local.enabled_app_services
  source   = "../azure-template/modules/appservice"

  app_name            = each.value.app_name
  resource_group_name = module.resource_group.name
  app_service_plan_id = module.app_service_plan[each.key].id
  kind                 = each.value.kind
  app_command_line     = try(each.value.startup_command, null)

  system_assigned_identity_enabled = true
  health_check_path                 = try(each.value.health_check_path, null)

  application_stack = merge(
    { current_stack = each.value.stack },
    each.value.stack == "dotnet" ? { dotnet_version = each.value.dotnet_version } : {},
    each.value.stack == "node" ? { node_version = each.value.node_version } : {},
    each.value.stack == "python" ? { python_version = each.value.python_version } : {}
  )
}

Use the compact features output to discover actual names and URLs:

cd ../azure-landingzone
terraform output -json features | jq '.enable_app_services.items'

If the local workspace does not hold the applied state:

az webapp list \
  --resource-group rg-platform-dev \
  --query "[?starts_with(name, 'web-platform-cc-dev-')].{name:name,state:state,host:defaultHostName}" \
  --output table

Deployment-method decision

MethodBuild locationRuntime contentBest fitReference implementation
ZIP deploy with OryxApp Service during deploymentSource ZIP built into runnable contentConventional Python layoutPrimary Python Azure DevOps pipeline
Prebuilt ZIPCI runnerReady-to-run extracted contentPublished .NET or self-contained appPrimary .NET pipeline
Run from packageCI runnerRead-only mounted ZIPNode.js and immutable packagesPrimary Node.js and alternate pipelines
Deployment CenterConnected repository plus platform buildBranch-derived contentFast bootstrap and portal-connected deliveryCurrent development Terraform selection
Custom containerContainer build runnerOCI imageNative packages, nonstandard runtime, portabilityDocumented extension; not implemented in the three app repos

Do not enable Oryx for a package that already includes runtime dependencies. Do not enable WEBSITE_RUN_FROM_PACKAGE=1 when the selected runtime must modify wwwroot. Never use a mutable container tag for production.

Stack-specific build contracts

Python

The GitHub workflow builds a self-contained Python package. The public mirror intentionally excludes .github; its behavior is recorded in the public Python pipeline guide. This excerpt comes from the reviewed source workflow:

- name: Validate application and dependencies
  run: |
    python -m pip install -r requirements.txt -r requirements-dev.txt
    ruff check .
    ruff format --check .
    pytest
    pip-audit -r requirements.txt

- name: Build self-contained application package
  run: |
    rm -rf "${PACKAGE_ROOT}"
    mkdir -p "${PACKAGE_ROOT}/.python_packages/lib/site-packages"
    python -m pip install \
      --target "${PACKAGE_ROOT}/.python_packages/lib/site-packages" \
      -r requirements.txt
    cp app.py requirements.txt "${PACKAGE_ROOT}/"
    cp -R templates static "${PACKAGE_ROOT}/"

The package must contain app.py, templates, static assets, and dependencies at the expected paths. Native wheels must match the App Service operating system, architecture, and Python version. A local virtual environment is not a deployment artifact.

The primary Azure DevOps path instead sends source and enables Oryx. This excerpt comes from azure-pipelines/deploy-stage.yml:

az webapp config set \
  --resource-group "${resource_group}" \
  --name "${app_name}" \
  --linux-fx-version "PYTHON|${pythonVersion}" \
  --startup-file "gunicorn --bind=0.0.0.0 --timeout 600 --access-logfile '-' --error-logfile '-' app:app"

az webapp config appsettings delete \
  --resource-group "${resource_group}" \
  --name "${app_name}" \
  --setting-names WEBSITE_RUN_FROM_PACKAGE PYTHONPATH || true

az webapp config appsettings set \
  --resource-group "${resource_group}" \
  --name "${app_name}" \
  --settings SCM_DO_BUILD_DURING_DEPLOYMENT=true ENABLE_ORYX_BUILD=true

az webapp deploy \
  --resource-group "${resource_group}" \
  --name "${app_name}" \
  --src-path "${packagePath}" \
  --type zip \
  --restart true \
  --track-status false

Node.js

Node.js delivery restores exactly from the lock file, validates, prunes development dependencies, and stages a self-contained package. This excerpt comes from azure-pipelines.yml:

- task: UseNode@1
  inputs:
    version: "$(nodeVersion)"

- script: npm ci
  displayName: Install dependencies

- script: npm run check
  displayName: Lint, test, and audit

- script: |
    npm prune --omit=dev
    npm run stage-package -- "$(packageRoot)"
  displayName: Stage application files

- task: ArchiveFiles@2
  inputs:
    rootFolderOrFile: "$(packageRoot)"
    includeRootFolder: false
    archiveType: zip
    archiveFile: "$(packagePath)"

The staged ZIP includes production node_modules, so the deploy path sets WEBSITE_RUN_FROM_PACKAGE=1 and disables remote build. The Linux application starts with cd /home/site/wwwroot && npm start. The server must listen on 0.0.0.0 and the port supplied by App Service.

.NET

.NET delivery tests and audits before packaging only dotnet publish output. The public mirror intentionally excludes .github; its behavior is recorded in the public .NET pipeline guide. This excerpt comes from the reviewed source workflow:

- name: Restore
  run: dotnet restore tests/WebCcoeDemo.Web.Tests/WebCcoeDemo.Web.Tests.csproj --configfile NuGet.config

- name: Run automated tests
  run: dotnet test tests/WebCcoeDemo.Web.Tests/WebCcoeDemo.Web.Tests.csproj --configuration "${BUILD_CONFIGURATION}" --no-restore

- name: Audit NuGet dependencies
  run: dotnet list "${PROJECT_FILE}" package --vulnerable --include-transitive

- name: Publish
  run: dotnet publish "${PROJECT_FILE}" --configuration "${BUILD_CONFIGURATION}" --output "${RUNNER_TEMP}/publish"

- name: Archive application
  run: |
    cd "${RUNNER_TEMP}/publish"
    zip -r "${RUNNER_TEMP}/app.zip" .

- name: Validate package root contains web.config
  run: unzip -l "${RUNNER_TEMP}/app.zip" | awk '{print $4}' | grep -x "web.config" > /dev/null

Do not ZIP project source as the runtime artifact. web.config keeps the package compatible with Windows App Service/IIS, while the published application also supports an appropriately configured Linux App Service.

Pipeline lifecycle

flowchart LR PR[Pull request] --> CHECK[Lint, test, audit] CHECK --> BUILD[Stack-specific build] BUILD --> ZIP[(app.zip)] ZIP --> PRE[Target and SCM precheck] PRE --> DEPLOY[Direct deploy on F1] DEPLOY --> HEALTH["/health validation"] HEALTH --> EVIDENCE[Run, commit, artifact, target] classDef gate fill:#15213b,stroke:#38d3ff,color:#e9eefc,stroke-width:2px; classDef artifact fill:#121d35,stroke:#ffbd38,color:#e9eefc,stroke-width:2px; classDef release fill:#121d35,stroke:#3edbb0,color:#e9eefc,stroke-width:2px; class PR,CHECK,PRE gate; class BUILD,ZIP artifact; class DEPLOY,HEALTH,EVIDENCE release;

The repositories add controls beyond a minimal deploy task:

  • least-privilege default workflow permissions;
  • branch or environment context resolution;
  • target existence checks;
  • SCM DNS/TCP checks;
  • temporary SCM allow rules when configured;
  • detection and removal of conflicting Deployment Center bindings;
  • runtime-appropriate application settings;
  • bounded health retries that reject redirects;
  • optional secondary targets and mirror publishing;
  • cleanup of temporary SCM rules.

GitHub Actions exercise

Select and inspect a repository

cd ../web-ccoedemo-python # repeat later for node and dotnet
git status --short
git branch --show-current

Read, in order:

  1. README.md;
  2. docs/ARCHITECTURE.md;
  3. docs/DEPLOYMENT_METHODS.md;
  4. the pipeline guide;
  5. .github/workflows/azure-webapp.yml.

Configure the protected environment

The reviewed workflows use repository variable DEPLOY_ENV to select a GitHub Environment. Configure that environment with the exact variable and secret names documented in the selected repository. Set the primary application target to only its matching development web app:

RepositoryPrimary target
web-ccoedemo-pythonweb-platform-cc-dev-python
web-ccoedemo-nodeweb-platform-cc-dev-node
web-ccoedemo-dotnetweb-platform-cc-dev-dotnet

Leave optional secondary and third targets blank during the lab.

The reviewed workflows authenticate with protected service-principal credentials. The target standard is GitHub OIDC with azure/login, id-token: write, an exact federated subject, and resource-group-scoped RBAC. Migrate only after the federated credential is ready and the new login has been proven; never silently fall back to a publish profile.

Run and validate

  1. Open a pull request and confirm validation completes without deployment.
  2. Review the package contents and dependency audit.
  3. Run the documented manual deployment or merge path.
  4. Confirm the workflow finds the expected App Service.
  5. Review any Deployment Center binding that it removes.
  6. Confirm SCM access is restored after deployment.
  7. Verify /health and record the workflow run URL, commit, artifact, environment, and target.

The checked-in health loop expects a nonredirecting HTTP 2xx response. Because public mirrors exclude .github, use the published Python pipeline guide, Node.js deployment guide, and .NET pipeline guide as the public references for those source workflows.

Azure DevOps exercise

Understand branch-to-environment behavior

The repositories deliberately differ:

  • Python and Node.js validate main, dev, and sbx; their dev branch targets the matching development App Service, while main is build-only in the reviewed Azure DevOps pipelines.
  • .NET uses its documented Sandbox-to-Dev sequence and targets web-platform-cc-dev-dotnet in the development stage.
  • run_from_package.yml is an alternate, manually selected path rather than an additional automatic deployment.

Use the selected repository's azure-pipelines.yml and azure-pipelines/deploy-stage.yml; do not copy a generic pipeline over them.

Configure identity and environments

Create or verify the named Azure Resource Manager service connections used by the pipeline. Prefer workload identity federation, scope access to the intended resource group, restrict pipeline authorization, and add approvals/checks to protected Azure DevOps environments.

Pipeline identity deploys content and configures the target. Runtime managed identity accesses Key Vault, Storage, databases, and other dependencies. These identities must not share permissions merely for convenience.

Run and validate

  1. Queue a PR validation build.
  2. Inspect test, audit, archive, and package-root validation.
  3. Run the repository's development branch/stage.
  4. Confirm SCM DNS and TCP 443 prechecks pass.
  5. Confirm stack-specific settings match the selected artifact style.
  6. Verify the target health endpoint.
curl --fail "https://web-platform-cc-dev-python.azurewebsites.net/health"
curl --fail "https://web-platform-cc-dev-node.azurewebsites.net/health"
curl --fail "https://web-platform-cc-dev-dotnet.azurewebsites.net/health"

Record the pipeline run, source commit, artifact, service connection, target, health result, and approval evidence.

Deployment Center and Terraform drift

The current development Terraform selects Deployment Center, while the repo-managed ZIP pipelines can remove a conflicting source binding. If Terraform remains unchanged, a later apply can restore that binding.

[!IMPORTANT] Treat this as an ownership conflict, not a pipeline nuisance. Update the Terraform deployment-method selection before adopting a repo-managed ZIP pipeline as the authoritative path.

Choose one method per target:

  • retain Deployment Center and do not run an independent ZIP deploy workflow against the same target; or
  • change deployment_method in azure-landingzone to the pipeline-aligned method, review the plan, apply it, and then use the application pipeline.

Never resolve this conflict only through the portal. Capture the authoritative choice in Terraform and the change record.

Configuration, secrets, and identity

  • Store nonsecret environment values in App Service settings or governed configuration.
  • Store secrets in Key Vault and prefer managed identity/Key Vault references.
  • Keep FLASK_SECRET_KEY, SESSION_SECRET, Entra credentials, and connection strings out of ZIP files and logs.
  • Treat pipeline deployment permission separately from application runtime permission.
  • Avoid publish profiles and SCM basic credentials where Microsoft Entra deployment is supported.
  • Mark settings sticky before a slot swap when they must remain with the environment.
  • Verify Easy Auth and application-managed MSAL independently; they are separate authorization layers.

The reference applications fail or degrade safely when required session/authentication configuration is absent. Do not weaken that behavior to make a deployment appear healthy.

Private networking and deployment agents

Private Endpoint controls inbound access. VNet Integration controls application outbound access. They solve different problems.

When the SCM endpoint is restricted or private:

  • use a runner/agent with approved network reachability;
  • resolve both <app>.azurewebsites.net and <app>.scm.azurewebsites.net correctly;
  • test TCP 443 before upload;
  • preserve least-privilege SCM access restrictions;
  • do not open the SCM endpoint broadly to make a pipeline pass.

The reference pipelines include SCM prechecks and can temporarily allow the current runner IP. A private endpoint requires a network-integrated runner rather than a temporary public rule.

Slot-promotion extension

F1 does not support slots. To perform this extension, change sku_name to S1 or another supported tier in azure-landingzone, review terraform plan, and apply through the governed infrastructure pipeline.

The target release flow is:

flowchart LR BUILD[Build once] --> ART[(Immutable ZIP or image digest)] ART --> SLOT[Deploy staging slot] SLOT --> TEST[Warm-up and smoke tests] TEST --> APPROVE[Approval] APPROVE --> SWAP[Swap to production] SWAP --> VERIFY[Production verification] classDef build fill:#121d35,stroke:#ffbd38,color:#e9eefc,stroke-width:2px; classDef gate fill:#15213b,stroke:#38d3ff,color:#e9eefc,stroke-width:2px; classDef release fill:#121d35,stroke:#3edbb0,color:#e9eefc,stroke-width:2px; class BUILD,ART build; class SLOT,TEST,APPROVE gate; class SWAP,VERIFY release;

Create and promote the slot with governed automation. Equivalent CLI operations are:

az webapp deployment slot create \
  --resource-group rg-platform-dev \
  --name web-platform-cc-dev-node \
  --slot staging

curl --fail "https://web-platform-cc-dev-node-staging.azurewebsites.net/health"

az webapp deployment slot swap \
  --resource-group rg-platform-dev \
  --name web-platform-cc-dev-node \
  --slot staging \
  --target-slot production

Use expand-and-contract database migrations. A slot swap cannot undo an incompatible schema change, deleted data, downstream contract change, or shared-secret failure.

Custom-container extension

Use a custom container when native system packages, a nonstandard runtime, or portability justify owning the runtime image.

  1. Pin the base image.
  2. Build and test the image.
  3. Scan it and generate an SBOM/provenance.
  4. Push a commit-addressed image to ACR.
  5. Record the digest.
  6. Grant the web app's managed identity only AcrPull.
  7. Deploy the exact digest and validate /health.

Do not deploy latest, store registry administrator credentials, write state into the container filesystem, or omit stdout/stderr logging.

Validation

Build and artifact

  • Lock-file-based restore is used where supported.
  • Lint, unit tests, dependency audit, and required security scans pass.
  • Only runtime files are packaged.
  • Required entry files are at the ZIP root.
  • The artifact is associated with the source commit and pipeline run.
  • No credential or .env file is present.

Deployment

  • The expected App Service exists and its OS/runtime match the package.
  • Deployment identity has only required access.
  • SCM DNS and TCP connectivity pass.
  • No competing Deployment Center binding remains unintentionally.
  • Build-during-deployment and run-from-package settings match the selected method.
  • Temporary SCM rules are removed.

Runtime

  • /health returns a nonredirecting HTTP 2xx response.
  • The application reports the expected release/commit where available.
  • MSAL and Easy Auth flows behave as designed.
  • Dependencies are reachable through the expected routes and DNS.
  • Logs, metrics, and traces arrive at the intended destination.
  • HTTP 5xx, latency, restarts, and dependency failures remain within thresholds.

Rollback

For direct ZIP deployment, redeploy the previously retained package without rebuilding it. For run from package, restore the previous package reference. For a custom container, restore the previous digest.

For a slot deployment with backward-compatible data changes, swap back:

az webapp deployment slot swap \
  --resource-group rg-platform-dev \
  --name web-platform-cc-dev-node \
  --slot staging \
  --target-slot production

After rollback, validate health, preserve diagnostics, stop further promotion, record the incident and artifact identifiers, and fix forward through a new reviewed build. Do not repeatedly swap when a shared dependency or schema is the actual failure.

Troubleshooting

SymptomLikely causeCorrection
Default App Service pageIncorrect ZIP root or startup failureInspect package root, deployment log, and application log
Python missing moduleOryx disabled for source ZIP or vendored path incorrectAlign SCM_DO_BUILD_DURING_DEPLOYMENT, PYTHONPATH, package layout, and Python version
Gunicorn cannot load appStartup module or working directory is wrongMatch app:app and /home/site/wwwroot to the checked-in Flask layout
Node returns 503Missing production dependencies or wrong startup/listen addressVerify node_modules, npm start, 0.0.0.0, and platform port
.NET cannot startSource rather than publish output was deployedRecreate ZIP from dotnet publish output and verify web.config
Deployment 401/403Wrong federated subject, secret, service connection, or RBACInspect login and role scope; do not substitute a publish profile silently
SCM timeoutAccess restriction, DNS, or private network pathRun the checked-in precheck and use a network-integrated agent when private
Terraform restores source bindingPipeline removed Deployment Center but IaC still selects itChange the authoritative deployment_method and apply reviewed Terraform
Health redirects to loginHealth endpoint is protected by Easy AuthKeep a minimal nonsecret readiness path reachable to the platform and pipeline
Runtime files disappearApp writes under read-only/ephemeral contentUse /home where appropriate or an external durable service

Useful diagnostics:

az webapp log config \
  --resource-group rg-platform-dev \
  --name web-platform-cc-dev-python \
  --application-logging filesystem \
  --level information

az webapp log tail \
  --resource-group rg-platform-dev \
  --name web-platform-cc-dev-python

Security and production hardening

  • Protect branches and require review of workflow, pipeline, and Terraform changes.
  • Pin third-party actions and shared pipeline templates according to supply-chain policy.
  • Add secret, SAST, dependency, IaC, and container scans as applicable.
  • Sign artifacts/images and retain provenance when supported.
  • Use separate identities, service connections, and approvals for each environment.
  • Enforce HTTPS, TLS policy, access restrictions, private connectivity, managed identity, and least privilege.
  • Configure diagnostics, Application Insights, deployment annotations, alerts, SLOs, and retention.
  • Select a production-capable tier and validate Always On, autoscale, zone resilience, backup, and recovery requirements.
  • Test startup, scaling, rollback, and full redeployment from retained artifacts.

Controlled failure exercises

Incorrect startup command

Change only a disposable target's startup command, observe the health failure and logs, then restore it through Terraform. This proves runtime troubleshooting and drift control.

Broken SCM route

From a nonauthorized runner, verify that SCM precheck fails before deployment. Repeat from an approved agent. Do not broaden the restriction.

Missing runtime permission

Remove a disposable managed identity role, confirm the application—not the pipeline—loses dependency access, restore least privilege, and retest. This demonstrates identity separation.

Operational ownership

ControlPrimary owner
Application build and health endpointApplication team
Pipeline patterns and shared templatesPlatform/DevOps team
Service connections and federationPlatform/security team
Environment approvalsEnvironment owner
Terraform and App Service runtime configurationPlatform and application teams
Runtime identity permissionsResource owner/security team
Monitoring and alertsApplication and operations teams
Rollback decisionIncident/change authority
Database migrationsApplication and database teams

Cleanup

The reference App Services belong to the shared landing zone. Do not delete rg-platform-dev or any of its resources as lab cleanup.

Remove only lab-specific identities, federated credentials, environments, service connections, variables, temporary branches, and runner registrations. Reconcile persistent configuration through andyxuan2010/azure-landingzone; do not reverse Terraform-owned settings manually.

Definition of done

The direct-deployment lab is complete when all three repositories build and test successfully, at least one GitHub Actions and one Azure DevOps deployment have been executed, each app reaches its matching target, /health passes, the deployment-method choice agrees with Terraform, and release evidence and a previous artifact are retained. The extension is complete when slot validation, approval, swap, production verification, and rollback have also been tested on a supported tier.

Official references