DevOps CI/CD

An Enterprise Branching Strategy + Multi-Stage CI/CD on Azure DevOps: GitFlow, PR/CI/CD, Seven Environments & Blue-Green Slots

A release manager once told me the most expensive line in their whole delivery system was a single sentence in a runbook: “merge to main and it goes to prod.” It sounds clean. It is a trap. The day a hotfix and a feature both needed to ship, the day an auditor asked “who approved the 14:32 production deploy,” the day a botched swap took the checkout API down for nine minutes — every one of those days traced back to a branching model that could not express the difference between work in progress, a candidate being hardened, and the exact bytes running in production. This article is about closing that gap. It is the branching-and-promotion half of a six-part enterprise CI/CD series, and it assumes the platform you are building on: an Azure DevOps organisation carved into purpose-built projects (IaC modules, centralized YAML templates, Azure Artifacts feeds, and many application projects), served by a shared self-hosted agent fleet on a Virtual Machine Scale Set in the hub network, deploying into a CAF landing zone of management groups, Key Vaults and a hub-spoke network.

We are going to wire two things together until they are inseparable: a branching strategy (GitFlow — feature/*developmentrelease/*main, plus hotfix/*, with semantic version tags) and a multi-stage CI/CD model built from three distinct pipeline types. The PR pipeline gates every merge with lint, code analysis, unit tests, coverage and Veracode scans. The CI pipeline pulls secrets from Key Vault, restores from the internal Azure Artifacts feed under strict enforcement, builds, scans, tests, and publishes versioned artifacts. The CD pipeline promotes one immutable build through seven environments — Dev → SIT → QA → Staging → UAT → Pre-Prod → Production — using Azure DevOps Environments with approvals and checks to make the production release genuinely gated, and App Service deployment slots to do blue-green so the swap is the only thing the user ever notices. And because the most senior thing in any delivery system is not the deploy but the un-deploy, we end on rollback you have actually rehearsed.

By the end you will be able to map any branch to a precise stage, write the branch policies that make development and main un-bypassable, build a CD stage template that warms a staging slot and swaps it under an approval gate, and roll a release back in under two minutes by swapping the slots back — without a redeploy. We will stay concrete throughout: real Azure DevOps YAML, real az/az pipelines CLI, real Terraform for the Environments and slots, real Veracode/Datadog/Liquibase mechanics. No invented product features, no “in today’s fast-paced world.” Just the system, end to end, the way a 22-year platform engineer wires it when an auditor is watching and the pager is armed.

What problem this solves

The pain is rarely a single broken deploy. It is the slow accumulation of ambiguity. Without a branching model that maps cleanly to environments, three failure modes recur in every team I have walked into. First, you cannot answer “what is in production right now.” If main is both where features land and where releases cut, then “the head of main” and “the bytes in prod” drift apart the moment someone merges after a release froze. Second, hotfixes corrupt the release train. An urgent prod fix committed to a long-lived integration branch either ships untested work alongside it, or forces a frantic cherry-pick that misses a file. Third, the gate is theatre. “We require approval” means nothing if the approver is the same person who wrote the change, or if anyone with push access can bypass the policy on a Friday night.

The multi-stage half solves a parallel set of problems. A single-stage “build-and-deploy” pipeline rebuilds per environment, so the artifact tested in QA is not the artifact that reaches production — a different compiler invocation, a newer transitive dependency, a moved feed package, and now your QA sign-off certifies bytes that no longer exist. It also conflates build concerns with release concerns: secrets, environment configuration, slot warm-up and approvals all get crammed into one YAML that no one can reason about. And it makes rollback a redeploy — pulling an old commit, rebuilding, hoping the feed still has the same versions — which is exactly the slow, error-prone path you do not want at 02:00 during an incident.

Who hits this: every enterprise past a handful of services. It bites hardest where compliance requires an audit trail (who approved, what scanned, what shipped), where multiple teams share environments and a bad merge blocks everyone, and where production is genuinely high-stakes — payments, health, anything with an SLA and a regulator. The reference platform here is built precisely for that world: the branching model expresses every state of the work, the three pipelines separate the concerns, the seven environments give risk somewhere to be caught, and blue-green slots plus a swap-back rollback make the riskiest moment — going live — both fast and reversible. To frame the whole field before we dive in, here is the spine of the system: every branch, the pipeline it triggers, the environment it targets, and the gate that guards it.

Branch Lifetime Triggers Lands in / promotes to Version tag cut here? Primary gate
feature/* Hours–days PR pipeline (on PR to development) Merged into development No PR policy: 1+ review, build green, scans pass
development Permanent CI pipeline → CD to Dev/SIT/QA Dev → SIT → QA No Branch policy + CI gate
release/x.y Days–weeks CI pipeline → CD to Staging/UAT/Pre-Prod Staging → UAT → Pre-Prod Release candidate (x.y.0-rc.n) Stabilisation gate; no new features
main Permanent CI pipeline → CD to Production Production Final (x.y.0) Production approval + checks
hotfix/* Hours PR + CI → CD fast-track to Prod Production, then back-merge Patch (x.y.z) Same prod gate, expedited approvers

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with Git (branches, merges, rebases, tags), with YAML pipelines in Azure DevOps (stages, jobs, steps, templates, parameters, variables), and with the App Service mental model: an App Service plan is the rented compute, a web app runs on it, and deployment slots are swappable copies of that app sharing the plan. You should know what a service connection is (the credential Azure DevOps uses to act against Azure), what a variable group is (and that it can be backed by Key Vault), and roughly what managed identity does. Familiarity with semantic versioning (MAJOR.MINOR.PATCH) is assumed.

This is article four of a six-part series and it sits on top of the platform the earlier parts establish. The organisation, project layout and shared agent fleet come from Enterprise Azure DevOps at Scale: Multi-Project Structure (IaC, Templates, Packages, Apps) + a Centralized VMSS Agent Fleet. The reusable YAML and the internal-feed enforcement this article leans on come from Centralized Azure Pipeline YAML Templates + Azure Artifacts Feeds: One Way to Build, One Trusted Dependency Source. The deploy target — the CAF management-group hierarchy, the Key Vault per scope, the hub-spoke network with the agent VMSS — is the Azure Enterprise-Scale Landing Zone. And the scan/test/observe/mobile/database mechanics that the pipelines call into are covered in Shift-Left Security, Testing, Observability — and Mobile + Database Delivery — on Azure DevOps. If GitFlow versus trunk-based is new to you, read Git Branching Strategies: Trunk-Based, GitFlow and Feature Branches first; if blue-green versus canary is, read Deployment Strategies: Blue-Green, Canary and Rolling Updates.

A quick map of which platform layer owns each concern in this article, so you know who to call when a stage fails:

Concern Where it lives Who usually owns it What this article does with it
Branch policies & merge rules Azure Repos (per repo) Platform + repo leads Defines the GitFlow policy set
Pipeline definitions App projects, referencing the templates project App teams (templates by platform) PR / CI / CD YAML and stage templates
Reusable templates Templates project Platform engineering extends / template: consumed here
Artifacts feeds Packages project Platform engineering CI publishes; CD never rebuilds
Shared agents VMSS in the hub Platform engineering Every job runs on the vmss-linux pool
Environments + approvals Azure DevOps Environments Platform + release managers Gates Staging/Pre-Prod/Production
App Service + slots Spoke (App Service) App + platform Blue-green swap target
Key Vault (per scope) Landing-zone scopes Platform + security Source of CI/CD secrets

Core concepts

Six mental models make every later decision obvious. Get these straight and the YAML writes itself.

A branch is a claim about a state of the work, not a folder. feature/* says “unverified, in progress.” development says “integrated, continuously verified, deployable to the lower environments.” release/x.y says “feature-frozen, being hardened into a shippable candidate.” main says “this is what is in (or last shipped to) production.” hotfix/* says “an urgent fix to live, bypassing the normal train.” The whole point of GitFlow over trunk-based-with-flags is that these states are named and enforced, which a regulated enterprise needs because an auditor asks questions in exactly those terms. Trunk-based is faster for high-maturity teams shipping many times a day; GitFlow is the right call when releases are batched, change advisory boards exist, and “what is the production source of truth” must have a one-word answer (main).

The artifact is immutable and built exactly once. The single most important rule in multi-stage CD: the CI pipeline produces one versioned build artifact, and every CD stage deploys that same artifact — Dev gets the same bytes as Production. You never rebuild per environment. This is what makes a QA sign-off mean something: the thing QA approved is the literal thing that ships. It is also what makes rollback fast — the old artifact is still in the pipeline’s artifact store and in the Artifacts feed, ready to redeploy without a compile.

Configuration is per-environment; code is not. If the artifact is identical everywhere, the difference between environments lives entirely in configuration: connection strings, feature flags, scale settings, secrets. In this platform that means a Key Vault per scope plus slot-sticky app settings, injected at deploy time. The deploy step is parameterised by environment; the artifact is not. Mixing config into the artifact (baking an appsettings.Production.json into the build) re-introduces the per-environment-build problem through the back door.

A pipeline type is a separation of concerns, not a file. “PR / CI / CD” are three responsibilities. The PR pipeline answers “is this change safe to merge?” — fast, read-only, no deploy. The CI pipeline answers “can we produce a trustworthy, versioned artifact from development/release/main?” — secrets, restore-with-enforcement, build, scan, test, publish. The CD pipeline answers “can we safely promote this exact artifact through the environments to production?” — deploy, validate, gate, swap, observe. Keeping them separate is what lets each be reasoned about, owned, and audited independently.

An environment is a risk-catching opportunity with a gate. Seven environments is not bureaucracy; each catches a different class of risk that the previous one cannot. Dev catches “does it deploy at all.” SIT (System Integration Test) catches “do the services talk to each other.” QA catches “does it pass the test suites.” Staging catches “does it run on production-like infra and config.” UAT (User Acceptance Test) catches “do the humans who asked for it accept it.” Pre-Prod catches “does it survive production-scale load and the real network.” Production is the goal. Azure DevOps Environments model these as first-class objects you attach approvals and checks to — so the gate is enforced by the platform, recorded, and not bypassable by a push.

Blue-green is two identical slots and a swap. An App Service deployment slot is a fully-functioning copy of your app on the same plan, with its own hostname. Blue-green means: production traffic is on the production slot (“blue”); you deploy the new release to the staging slot (“green”); you warm and validate green privately; then you swap — Azure re-points the production hostname to the green instance after the platform confirms it responds. The swap is near-instant and, crucially, reversible: swap again and blue is live. That reversibility is your fastest rollback. The subtlety is slot settings — which app settings stick to a slot and which travel with the swap — and warm-up, because a cold green slot that swaps in produces exactly the cold-start latency users notice.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary at the end repeats these for lookup; this is the mental model side by side.

Term One-line definition Lives where Why it matters here
GitFlow Branch model: feature/development/release/main + hotfix Azure Repos Maps states of work to environments
development Permanent integration branch A repo Source for Dev/SIT/QA
release/x.y Short-lived hardening branch A repo Source for Staging/UAT/Pre-Prod
main Production source of truth A repo Source for Production; tagged x.y.0
hotfix/* Urgent fix branched off main A repo Fast-track to prod, then back-merge
Semantic tag MAJOR.MINOR.PATCH annotated tag On a commit Names the immutable release
Branch policy Enforced merge rules Per branch Makes the gate un-bypassable
PR pipeline Pre-merge validation (no deploy) Pipelines “Is this safe to merge?”
CI pipeline Build-test-publish, makes the artifact Pipelines “Produce one trustworthy build”
CD pipeline Multi-stage promotion to prod Pipelines “Promote that build safely”
Pipeline artifact The immutable published build Pipeline run Deployed unchanged to every env
Environment First-class deploy target object Azure DevOps Anchors approvals + checks
Approval / check Gate on an Environment On an Environment Enforces the gated release
Deployment slot Swappable copy of an App Service The App Service Blue-green green slot
Swap Re-point prod hostname to a slot App Service Go-live + instant rollback
Slot setting App setting pinned to a slot App config Keeps prod config from travelling
Variable group Named set of pipeline variables Library Per-env config, Key Vault-backed
Service connection Credential to act on Azure Project settings How CD authenticates to deploy

The branching strategy in depth

GitFlow on this platform is not the textbook diagram — it is the textbook diagram bound to seven environments and three pipelines. Walk the lifecycle of one change end to end, then we will enumerate every branch, policy and tag.

A developer cuts feature/PAY-1421-idempotency-keys off development. They push commits; opening a pull request back into development triggers the PR pipeline (never a deploy). Lint, static analysis, unit tests, coverage threshold and the Veracode SCA/pipeline/container scans must go green, and at least one human must approve, before the branch policy will allow the merge. On merge, development advances and its CI pipeline fires: it builds one artifact, versions it, scans and tests it, publishes it, and the CD pipeline rolls that artifact through Dev → SIT → QA. Features accumulate on development until the team decides to ship a release.

To ship, someone cuts release/2.4 off development. This branch is feature-frozen — only bug fixes and stabilisation land here. Its CI builds a release-candidate artifact (2.4.0-rc.1) and CD promotes it through Staging → UAT → Pre-Prod, where it faces production-like infra, real user acceptance, and production-scale load. Each fix on release/2.4 bumps the rc (-rc.2, -rc.3). When the candidate is accepted, release/2.4 is merged into main and an annotated tag 2.4.0 is cut on that merge commit. main’s CI builds the final artifact from the tagged commit; CD takes it through the gated Production stage. Finally main is merged back into development so the fixes made during stabilisation are not lost. If production then breaks, hotfix/2.4.1 branches off main, gets the expedited prod gate, ships as tag 2.4.1, and is merged back into both main and development.

Every branch, enumerated

The full branch reference — naming, base, merge target, who can push, and the merge strategy the policy enforces.

Branch type Example Branches from Merges to Direct push allowed? Enforced merge strategy
Integration development (long-lived) release/* (by cut) No (PR only) Squash on feature merge
Feature feature/PAY-1421-... development development Author only, on the branch Squash (1 commit per feature)
Release release/2.4 development main and back to development Leads only Merge commit (no-FF, preserves history)
Production main (long-lived) back to development No (PR/merge only) Merge commit
Hotfix hotfix/2.4.1 main main and development Leads only Merge commit
Bugfix (on release) bugfix/PAY-1500-... release/2.4 release/2.4 Author only Squash

Branch naming convention

Consistency here is what lets pipeline trigger filters and path filters work. The convention this platform enforces (validated by a PR check that rejects non-conforming names):

Pattern Use Example
feature/<JIRA>-<slug> New work toward development feature/PAY-1421-idempotency-keys
bugfix/<JIRA>-<slug> Fix on a release branch bugfix/PAY-1500-null-on-refund
release/<MAJOR>.<MINOR> Hardening a version release/2.4
hotfix/<MAJOR>.<MINOR>.<PATCH> Urgent prod fix hotfix/2.4.1
spike/<slug> Throwaway exploration (no PR) spike/grpc-streaming-test

Semantic version tags

Tags are how an immutable, human-meaningful name attaches to the exact commit a release was built from. Use annotated tags (they carry author, date and message and are what git describe and the pipeline read), never lightweight ones. The scheme and what bumps each component:

Tag Bumps when Cut on Built by Example
MAJOR (3.0.0) Breaking API/contract change main merge main CI 3.0.0
MINOR (2.4.0) Backward-compatible feature release main merge main CI 2.4.0
PATCH (2.4.1) Bug/security fix, no new features main merge (from hotfix) main CI 2.4.1
-rc.N (2.4.0-rc.3) Each release-candidate iteration release/* release CI 2.4.0-rc.3
Build metadata (+<buildId>) Every CI run (traceability) any any CI 2.4.0+20260628.4

Cutting the tag from the pipeline (so it is automatic and tied to the exact built commit) uses git tag -a against the checked-out SHA. A minimal CI step that tags main builds:

- ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }}:
  - script: |
      VERSION=$(cat version.txt)               # e.g. 2.4.0, single source of truth in the repo
      git tag -a "$VERSION" -m "Release $VERSION (build $(Build.BuildId))"
      git push origin "$VERSION"
    displayName: 'Tag the release on main'
    env:
      # the agent's checkout uses the build service identity; it needs Contribute + Create-tag on the repo
      GIT_AUTHOR_NAME: 'Azure DevOps'

Why GitFlow here and not trunk-based

This decision gets re-litigated in every architecture review, so make it explicitly. The trade-off, dimension by dimension:

Dimension GitFlow (this platform) Trunk-based + feature flags
Release cadence fit Batched, scheduled releases Many deploys/day
“What’s in prod?” answer main / latest tag — one word The flag state — needs a lookup
Audit & CAB fit Native (named branches, tags, gates) Needs extra tooling to reconstruct
Hotfix isolation Clean (hotfix/* off main) Cherry-pick / flag-off
Merge overhead Higher (more long-lived branches) Lower
Risk of long-lived divergence Higher (release branches drift) Lower (everything merges fast)
Best when Regulated, batched, gated High-maturity, high-frequency

If your org is shipping ten times a day with mature flagging, trunk-based wins — see Progressive Delivery with Feature Flags. This platform chose GitFlow because releases are scheduled, a change advisory board signs off, and “what is in production” must be answerable without a tooling query.

Branch policies: making the gate real

A branching model is only as strong as the policies that enforce it. In Azure DevOps these attach to a branch and block the “Complete” button until satisfied. The full policy set this platform applies to development and main (and a lighter set to release/*):

Policy development main release/* What it enforces
Require a minimum number of reviewers 1 (2 for sensitive paths) 2 1 Human review before merge
Prohibit the most recent pusher from approving On On On The author can’t self-approve
Reset approvals on new push On On On Re-review after changes
Check for linked work items On (warn) On (require) On (require) Traceability to Jira
Check for comment resolution On On On No open threads at merge
Build validation (PR pipeline) Required Required Required Tests/scans green before merge
Status checks (Veracode policy) Required Required Required External scan gate
Limit merge types Squash only Merge commit only Squash only Clean, intentional history
Automatically include reviewers (path) CODEOWNERS-style groups + security team app leads Right eyes on right files
Bypass policies permission Denied to all Denied to all Lead group only Closes the back door

The single most important row is the last one. A policy that anyone with “Bypass policies when completing pull requests” can ignore is not a control. Lock it down explicitly.

Configuring policies as code

Branch policies are clickable in the UI, but on a platform with many repos you set them via the az repos CLI (and increasingly the azuredevops Terraform provider) so they are consistent and reviewable. Setting build validation and minimum reviewers on main:

# Resolve the repo id
REPO_ID=$(az repos show --repository payments-api --query id -o tsv)

# Require the PR pipeline (build validation) to pass on PRs into main
az repos policy build create \
  --repository-id "$REPO_ID" --branch main \
  --build-definition-id 142 \
  --display-name "PR build must pass" \
  --manual-queue-only false --queue-on-source-update-only true \
  --valid-duration 720 --blocking true --enabled true

# Require 2 reviewers, block self-approval, reset on push
az repos policy approver-count create \
  --repository-id "$REPO_ID" --branch main \
  --minimum-approver-count 2 \
  --creator-vote-counts false \
  --reset-on-source-push true \
  --allow-downvotes false \
  --blocking true --enabled true

The equivalent with the Terraform azuredevops provider, which is how the platform keeps it in the IaC project:

resource "azuredevops_branch_policy_min_reviewers" "main" {
  project_id = var.project_id
  enabled    = true
  blocking   = true

  settings {
    reviewer_count                         = 2
    submitter_can_vote                     = false   # author can't approve
    last_pusher_cannot_approve             = true
    on_push_reset_approved_votes           = true
    allow_completion_with_rejects_or_waits = false

    scope {
      repository_id  = azuredevops_git_repository.payments_api.id
      repository_ref = "refs/heads/main"
      match_type     = "Exact"
    }
  }
}

resource "azuredevops_branch_policy_build_validation" "main" {
  project_id = var.project_id
  enabled    = true
  blocking   = true

  settings {
    display_name                = "PR build must pass"
    build_definition_id         = azuredevops_build_definition.pr_pipeline.id
    valid_duration              = 720   # minutes; expires stale validations
    queue_on_source_update_only = true

    scope {
      repository_id  = azuredevops_git_repository.payments_api.id
      repository_ref = "refs/heads/main"
      match_type     = "Exact"
    }
  }
}

Path filters and CODEOWNERS-style reviewers

On a service with mixed content you do not want a one-line README change to demand the security team. Path filters scope build validation and automatic reviewers scope who is required by file. Map the sensitive paths explicitly:

Path glob Auto-included reviewer group Min reviewers Build validation
/src/payments/** Payments leads 2 Full PR pipeline
/infra/** (Terraform) Platform + security 2 Plan + tfsec/Checkov
/db/changelog/** (Liquibase) DBA group 2 Liquibase validate
/docs/**, *.md (none required) 1 Lint only
/.azure-pipelines/** Platform engineering 2 Template lint

Pipeline type 1 — the PR pipeline (the merge gate)

The PR pipeline has one job: decide whether a change is safe to merge. It is fast, read-only, and never deploys. It triggers from the branch policy’s build validation on a PR into development, release/* or main. Everything it runs must be a gate — green to allow the merge, red to block it.

What it runs, in order, and why each is here:

Stage / step Tool Gate condition Typical time Why pre-merge
Branch-name check Script Name matches convention <5 s Keep filters working
Lint / format language linter No errors 10–40 s Style is cheap to enforce early
Static code analysis SonarQube / built-in No new blocker issues 1–4 min Catch bugs before review
Unit tests test runner All pass 1–6 min Fastest correctness signal
Code coverage coverage tool ≥ threshold (e.g. 80% on new code) (with tests) Stop coverage erosion
Veracode SCA Veracode No new high/critical CVE 1–3 min Vulnerable deps caught early
Veracode Pipeline Scan Veracode No new flaws above policy 1–5 min Fast SAST on the diff
Veracode Container scan Veracode Base image within policy 1–4 min Image CVEs before merge
PR review Humans 1–2 approvals, no open threads Judgement the machine lacks

A representative PR pipeline that extends the centralized template (so every repo gets the same gate) and never deploys:

# azure-pipelines-pr.yml  (referenced by the branch policy build validation)
trigger: none                     # PR-only; the policy queues it, not a branch push
pr:
  branches:
    include: [ development, main, 'release/*' ]
  paths:
    exclude: [ docs/*, '**/*.md' ]   # docs PRs skip the heavy gate

resources:
  repositories:
    - repository: templates
      type: git
      name: Platform/pipeline-templates    # the centralized templates project
      ref: refs/tags/templates-3.2.0        # pinned template version

extends:
  template: pr/pr-gate.yml@templates
  parameters:
    language: dotnet
    coverageThreshold: 80
    veracode:
      sandboxScan: true            # Pipeline Scan (fast), not the full Policy scan
    pool: vmss-linux               # the shared VMSS agent pool in the hub

And a sketch of the template it extends, showing the gate shape — note unit-tests and the Veracode steps run as required steps, and there is no deploy:

# pr/pr-gate.yml  (in the templates project)
parameters:
  - name: language
    type: string
  - name: coverageThreshold
    type: number
    default: 80
  - name: pool
    type: string
  - name: veracode
    type: object
    default: {}

stages:
  - stage: Validate
    displayName: 'PR Gate'
    jobs:
      - job: gate
        pool: ${{ parameters.pool }}
        steps:
          - template: steps/branch-name-check.yml
          - template: steps/lint.yml
            parameters: { language: ${{ parameters.language }} }
          - template: steps/unit-tests.yml
            parameters:
              language: ${{ parameters.language }}
              coverageThreshold: ${{ parameters.coverageThreshold }}
          - template: steps/veracode-pipeline-scan.yml
            parameters: ${{ parameters.veracode }}
          - template: steps/veracode-sca.yml
          - template: steps/veracode-container-scan.yml

What the PR pipeline must never do

This list prevents the most common anti-patterns, where a PR pipeline grows into a slow, side-effecting monster that teams start skipping:

Never Why Where it belongs instead
Deploy anything A PR is unmerged, unversioned CD pipeline
Publish artifacts to the feed The change isn’t merged yet CI pipeline
Run full integration tests against shared envs Slow, flaky, blocks merges CI (integration) / CD (smoke)
Use long-lived production secrets Blast radius on a fork PR Read-only, scoped, or none
Take > ~10 min Developers context-switch and rage-skip Push slow checks to CI
Mutate the database Side effects from unmerged code CI/CD against owned envs

Pipeline type 2 — the CI pipeline (build, scan, test, publish)

When a merge lands on development, release/* or main, the CI pipeline runs. Its job is to produce one trustworthy, versioned, immutable artifact and publish it. This is the only pipeline that builds. Everything downstream consumes its output unchanged. The defining behaviours of this platform’s CI:

The CI flow, step by step:

Phase Step Tool Output / gate
Setup Link Key Vault variable group Key Vault + MI Secrets in-memory only
Restore Restore with feed enforcement Artifacts feed Fails on public-source pull
Build Compile + stamp version language toolchain Versioned binaries
Scan Veracode SCA on built deps Veracode No new high/critical
Test Unit + integration tests test runner All pass
Package Produce artifact (zip / image / package) toolchain / Docker Immutable artifact
Publish Pipeline artifact + feed package Azure DevOps / feed Available to CD
Tag Annotated version tag (on main) git Immutable release name

A CI pipeline that extends the centralized CI template, links Key Vault, enforces the feed, and publishes:

# azure-pipelines-ci.yml
trigger:
  branches:
    include: [ development, main, 'release/*' ]
  paths:
    exclude: [ docs/*, '**/*.md' ]
pr: none                              # CI runs on merge, not on PR

resources:
  repositories:
    - repository: templates
      type: git
      name: Platform/pipeline-templates
      ref: refs/tags/templates-3.2.0

variables:
  - group: payments-ci-secrets        # Key Vault-backed variable group (DB password, Veracode keys)

extends:
  template: ci/build-test-publish.yml@templates
  parameters:
    language: dotnet
    feed: Platform/corp-nuget          # internal feed; enforcement is inside the template
    enforceInternalFeed: true
    runIntegrationTests: true
    publishImage: true
    acr: corpacr.azurecr.io
    pool: vmss-linux

The feed-enforcement mechanic that makes “internal only” real — a NuGet example where the build uses only the org feed and fails if anything resolves elsewhere:

# steps/restore-enforced.yml  (inside the CI template)
steps:
  - task: NuGetAuthenticate@1        # auth the agent to the org feed via the build identity
  - script: |
      # nuget.config in the repo lists ONLY the internal feed as <packageSources>,
      # with <clear/> first so no public nuget.org leaks in.
      dotnet restore --configfile ./nuget.config --locked-mode
      # --locked-mode fails if packages.lock.json would change: no silent new/transitive deps.
    displayName: 'Restore (internal feed only, locked)'

Pulling a secret at runtime instead of (or in addition to) the variable group, scoped to the exact secrets the build needs:

- task: AzureKeyVault@2
  inputs:
    azureSubscription: 'sc-payments-nonprod'   # the service connection (MI-backed)
    KeyVaultName: 'kv-payments-nonprod'
    SecretsFilter: 'veracode-api-id,veracode-api-key,sql-app-password'
    RunAsPreJob: true
  displayName: 'Fetch CI secrets from Key Vault'

Publishing the immutable artifact that CD will consume:

- task: PublishPipelineArtifact@1
  inputs:
    targetPath: '$(Build.ArtifactStagingDirectory)/app'
    artifact: 'app-$(version)'      # name carries the version → traceable, immutable
  displayName: 'Publish build artifact'

Non-Prod vs Prod CI — separate pipelines, separate identities

Mirroring the IaC pattern of separate Non-Prod and Prod apply pipelines, the platform runs separate CI pipelines (or stages) per environment class, each with its own service connection and Key Vault scope, so a Non-Prod build can never touch Prod secrets. The split:

Aspect Non-Prod CI (development, release/*) Prod CI (main)
Service connection sc-payments-nonprod sc-payments-prod (restricted)
Key Vault scope kv-payments-nonprod kv-payments-prod
Who can run App team App team + release approval to publish
Tag cut -rc.N Final x.y.z
Feed view Same internal feed Same internal feed
Triggers development, release/* main only

Pipeline type 3 — the multi-stage CD pipeline

This is where the article lives. The CD pipeline takes the one artifact the CI pipeline published and promotes it through seven environments, gating where risk demands it and using blue-green slots for the live tiers. The promotion path is fixed and ordered:

Dev → SIT → QA → Staging → UAT → Pre-Prod → Production.

Each stage is the same logic (a reusable stage template) parameterised by environment: which App Service, which slot strategy, which Key Vault/variable group, which tests, which approvals. The progression of what each stage does and how it is gated:

# Stage Purpose Source branch Deploy style Tests run Gate
1 Dev Does it deploy + boot? development Direct to default slot Smoke Auto (none)
2 SIT Do services integrate? development Direct to default slot Integration (WebdriverIO API) Auto on Dev success
3 QA Do the test suites pass? development Direct to default slot UI (Playwright) + API Auto; QA can hold
4 Staging Prod-like infra/config? release/* Slot deploy (no swap) Smoke on slot Manual approval (QA lead)
5 UAT Do users accept it? release/* Slot deploy Acceptance (manual + Playwright) Business approval
6 Pre-Prod Survives prod-scale load + real network? release/* Slot deploy + Azure Load Test (JMX) Load + smoke Manual approval (perf)
7 Production Go live main (tagged) Slot deploy → warm → swap Post-swap smoke + Datadog Gated: leads + prod managers

The CD pipeline shell — note dependsOn chains the stages, each stage’s environment: is the Azure DevOps Environment that carries its approvals/checks, and the same deploy-stage.yml template is reused with different parameters:

# azure-pipelines-cd.yml
trigger: none                         # CD is triggered by a successful CI run, not a push
resources:
  pipelines:
    - pipeline: ci                    # consume the CI pipeline's artifact
      source: payments-api-ci
      trigger:
        branches:
          include: [ development, 'release/*', main ]
  repositories:
    - repository: templates
      type: git
      name: Platform/pipeline-templates
      ref: refs/tags/templates-3.2.0

stages:
  # ---- Lower environments: development branch ----
  - template: cd/deploy-stage.yml@templates
    parameters:
      stage: Dev
      environment: payments-dev            # AzDO Environment (no approval)
      webapp: app-payments-dev
      slotStrategy: direct
      variableGroup: payments-dev
      tests: [ smoke ]
      condition: eq(variables['resources.pipeline.ci.sourceBranch'], 'refs/heads/development')

  - template: cd/deploy-stage.yml@templates
    parameters:
      stage: SIT
      environment: payments-sit
      dependsOn: Dev
      webapp: app-payments-sit
      slotStrategy: direct
      variableGroup: payments-sit
      tests: [ integration ]

  - template: cd/deploy-stage.yml@templates
    parameters:
      stage: QA
      environment: payments-qa
      dependsOn: SIT
      webapp: app-payments-qa
      slotStrategy: direct
      variableGroup: payments-qa
      tests: [ ui, api ]

  # ---- Pre-production: release/* branch ----
  - template: cd/deploy-stage.yml@templates
    parameters:
      stage: Staging
      environment: payments-staging          # approval: QA lead
      dependsOn: QA
      webapp: app-payments-prod              # same app as prod...
      slotStrategy: slot                     # ...deploy to the 'staging' slot, no swap
      slot: staging
      variableGroup: payments-staging
      tests: [ smoke ]

  - template: cd/deploy-stage.yml@templates
    parameters:
      stage: UAT
      environment: payments-uat              # approval: business
      dependsOn: Staging
      webapp: app-payments-uat
      slotStrategy: slot
      slot: staging
      variableGroup: payments-uat
      tests: [ acceptance ]

  - template: cd/deploy-stage.yml@templates
    parameters:
      stage: PreProd
      environment: payments-preprod          # approval: perf lead
      dependsOn: UAT
      webapp: app-payments-preprod
      slotStrategy: slot
      slot: staging
      variableGroup: payments-preprod
      tests: [ load, smoke ]

  # ---- Production: main (tagged) ----
  - template: cd/deploy-stage.yml@templates
    parameters:
      stage: Production
      environment: payments-production       # gated: leads + prod managers + business hours
      dependsOn: PreProd
      webapp: app-payments-prod
      slotStrategy: blue-green               # deploy staging slot → warm → swap
      slot: staging
      variableGroup: payments-production
      tests: [ smoke ]
      condition: eq(variables['resources.pipeline.ci.sourceBranch'], 'refs/heads/main')

The reusable deploy-stage template

Every stage above calls one template. Its skeleton shows the deployment-job pattern — deployment: jobs bind to an environment: (which is what triggers approvals), strategy: runOnce gives preDeploy/deploy/routeTraffic/postRouteTraffic/on: hooks, and the slot logic branches on slotStrategy:

# cd/deploy-stage.yml  (templates project)
parameters:
  - name: stage
    type: string
  - name: environment
    type: string
  - name: webapp
    type: string
  - name: slotStrategy
    type: string            # direct | slot | blue-green
    default: direct
  - name: slot
    type: string
    default: production
  - name: variableGroup
    type: string
  - name: tests
    type: object
    default: []
  - name: dependsOn
    type: object
    default: []
  - name: condition
    type: string
    default: succeeded()

stages:
  - stage: ${{ parameters.stage }}
    dependsOn: ${{ parameters.dependsOn }}
    condition: ${{ parameters.condition }}
    variables:
      - group: ${{ parameters.variableGroup }}    # per-env, Key Vault-backed
    jobs:
      - deployment: deploy
        environment: ${{ parameters.environment }}  # ← approvals/checks fire HERE
        pool: vmss-linux
        strategy:
          runOnce:
            deploy:
              steps:
                - download: ci
                  artifact: app-$(version)          # the ONE immutable artifact
                # ---- direct deploy (lower envs) ----
                - ${{ if eq(parameters.slotStrategy, 'direct') }}:
                  - template: steps/deploy-webapp.yml
                    parameters: { webapp: ${{ parameters.webapp }}, slot: production }
                # ---- slot deploy, no swap (Staging/UAT/PreProd) ----
                - ${{ if eq(parameters.slotStrategy, 'slot') }}:
                  - template: steps/deploy-webapp.yml
                    parameters: { webapp: ${{ parameters.webapp }}, slot: ${{ parameters.slot }} }
                # ---- blue-green: deploy slot → warm → swap (Production) ----
                - ${{ if eq(parameters.slotStrategy, 'blue-green') }}:
                  - template: steps/deploy-webapp.yml
                    parameters: { webapp: ${{ parameters.webapp }}, slot: ${{ parameters.slot }} }
                  - template: steps/warmup-slot.yml
                    parameters: { webapp: ${{ parameters.webapp }}, slot: ${{ parameters.slot }} }
                  - template: steps/swap-slot.yml
                    parameters: { webapp: ${{ parameters.webapp }}, slot: ${{ parameters.slot }} }
            on:
              failure:
                steps:
                  - template: steps/notify-datadog.yml
                    parameters: { event: deploy_failed, stage: ${{ parameters.stage }} }

What separates each environment

The reason there are seven, not three. Each environment’s distinguishing infra, data and config — this is the table that justifies the cost of running them:

Env Infra fidelity Data Scale Config source Who signs off
Dev Minimal (B-tier) Synthetic 1 instance kv-...-nonprod Auto
SIT Service deps wired Synthetic + stubs 1 instance kv-...-nonprod Auto
QA Like Staging, smaller Curated test set 1–2 kv-...-nonprod QA team
Staging Production-identical SKU Masked prod-like Prod-like kv-...-prod-shaped QA lead
UAT Production-like Business test data Prod-like UAT vault Business owner
Pre-Prod Production clone Masked prod snapshot Production scale Prod-shaped Perf lead
Production Production Real Real kv-...-prod Leads + prod mgrs

Blue-green with App Service deployment slots

The live tiers (Pre-Prod and Production) do not deploy over the running app. They deploy to a staging slot, warm it, validate it, and swap. This is blue-green: the running production instance is “blue,” the freshly-deployed slot is “green,” and the swap atomically re-points the production hostname to green. Understanding the swap precisely is what makes it safe.

What the swap actually does

A swap is not a copy of files. Azure applies the target slot’s app settings/connection strings to the source slot’s instances, restarts and warms them, waits for them to respond, then switches the routing rules so the production hostname now points at those (now-warm) instances — and the old production instances become the staging slot. The mechanics that matter:

Property Behaviour Consequence
Warm-up before routing Target settings applied to source, instances restarted + pinged A cold slot is warmed before it serves users
Atomic hostname re-point Routing switches once instances are responsive Near-zero downtime; no half-swapped state
Slot settings stay put “Slot setting”-marked config does not travel Prod keeps prod connection strings
Reverse is a swap Swapping again restores the prior state Instant rollback
In-flight requests Existing connections drain on old instances No mid-request kills if drained

Slot settings vs travelling settings — the make-or-break detail

When you swap, app settings and connection strings marked as “deployment slot settings” (sticky) stay with the slot; everything else travels with the app. Get this wrong and your green slot swaps into production carrying staging’s database connection string, or production’s ASPNETCORE_ENVIRONMENT becomes Staging. The rule of thumb: anything that identifies the environment is a slot setting; anything that is the application’s behaviour travels. The canonical split:

Setting Slot-sticky? Why
ASPNETCORE_ENVIRONMENT / ENV Sticky Identifies the environment; must not travel
Database connection string Sticky Prod must keep the prod DB after swap
APPLICATIONINSIGHTS_CONNECTION_STRING / Datadog DD_ENV Sticky Telemetry must stay per-env
Key Vault references (vault URI) Sticky Each env points at its own vault
Feature flag connection (App Config endpoint) Sticky Per-env config store
Feature flag values Travels They’re part of the release behaviour
WEBSITE_RUN_FROM_PACKAGE artifact pointer Travels It IS the code being promoted
Build/version label Travels The version is the release

Marking a setting sticky via az (the --slot-settings flag is what makes it stay):

# These names will NOT travel on swap — they pin to whichever slot they're set on
az webapp config appsettings set \
  --name app-payments-prod --resource-group rg-payments-prod --slot production \
  --slot-settings ASPNETCORE_ENVIRONMENT=Production DD_ENV=production

az webapp config connection-string set \
  --name app-payments-prod --resource-group rg-payments-prod --slot production \
  --slot-settings --connection-string-type SQLAzure \
  --settings Default="@Microsoft.KeyVault(SecretUri=https://kv-payments-prod.vault.azure.net/secrets/sql-conn/)"

In Terraform, sticky_settings declares which keys never travel — this is how the platform keeps it from drifting:

resource "azurerm_linux_web_app" "payments" {
  name                = "app-payments-prod"
  resource_group_name = azurerm_resource_group.payments.name
  location            = var.location
  service_plan_id     = azurerm_service_plan.payments.id

  site_config {
    always_on = true                       # mandatory for warm slots + warm-up
    application_stack { dotnet_version = "8.0" }
  }

  app_settings = {
    ASPNETCORE_ENVIRONMENT = "Production"
    DD_ENV                 = "production"
  }

  # These keys are pinned to the slot they live on; a swap does NOT carry them across.
  sticky_settings {
    app_setting_names       = ["ASPNETCORE_ENVIRONMENT", "DD_ENV", "APPLICATIONINSIGHTS_CONNECTION_STRING"]
    connection_string_names = ["Default"]
  }
}

resource "azurerm_linux_web_app_slot" "staging" {
  name           = "staging"
  app_service_id = azurerm_linux_web_app.payments.id

  site_config {
    always_on = true
    application_stack { dotnet_version = "8.0" }
  }

  app_settings = {
    ASPNETCORE_ENVIRONMENT = "Staging"     # sticky → stays on the staging slot
    DD_ENV                 = "staging"
  }
}

Warming the slot before the swap

A swap warms the target slot’s instances after applying source settings, but you still want the green slot already warm and validated before you initiate the swap, so the swap is a formality, not a gamble. Two levers: Always On (keeps the worker resident so it is never idle-unloaded) and application initialization warm-up (applicationInitialization paths the platform hits before considering the instance ready). Set Always On and the warm-up paths so the slot is hot:

# Always On on the slot (prevents idle unload of the green slot)
az webapp config set --name app-payments-prod --resource-group rg-payments-prod \
  --slot staging --always-on true

# Tell App Service which paths to hit to warm the slot before it's "ready"
az webapp config appsettings set --name app-payments-prod --resource-group rg-payments-prod \
  --slot staging \
  --settings WEBSITE_SWAP_WARMUP_PING_PATH=/health/ready WEBSITE_SWAP_WARMUP_PING_STATUSES=200

The warm-up step in the deploy template actively pings the slot’s own hostname and fails the deploy if it does not go healthy — so a broken green slot never reaches the swap:

# steps/warmup-slot.yml
steps:
  - script: |
      SLOT_URL="https://app-payments-prod-staging.azurewebsites.net/health/ready"
      for i in $(seq 1 30); do
        code=$(curl -s -o /dev/null -w "%{http_code}" "$SLOT_URL")
        echo "warm-up attempt $i → HTTP $code"
        [ "$code" = "200" ] && exit 0
        sleep 10
      done
      echo "##vso[task.logissue type=error]Staging slot never returned 200 on /health/ready"
      exit 1
    displayName: 'Warm + validate the green slot'

The swap step

Once green is warm and validated, swap. The deploy template does it via az webapp deployment slot swap, which is the same operation the portal “Swap” button performs:

# steps/swap-slot.yml
steps:
  - task: AzureCLI@2
    inputs:
      azureSubscription: 'sc-payments-prod'
      scriptType: bash
      scriptLocation: inlineScript
      inlineScript: |
        az webapp deployment slot swap \
          --name app-payments-prod --resource-group rg-payments-prod \
          --slot staging --target-slot production
    displayName: 'Swap staging → production (go live)'

Swap-with-preview (two-phase) for the highest-risk releases

For releases where you want to validate production config on the green instances before committing, use swap with preview: phase one applies the production slot settings to the staging instances (so they are running production config) without changing routing; you validate; phase two completes the swap. The two-phase commands:

# Phase 1: apply target (production) config to the staging instances, do NOT route yet
az webapp deployment slot swap --action preview \
  --name app-payments-prod --resource-group rg-payments-prod \
  --slot staging --target-slot production
# ... run your verification against the staging slot now carrying prod config ...

# Phase 2: complete the swap (route production traffic to it)
az webapp deployment slot swap --action swap \
  --name app-payments-prod --resource-group rg-payments-prod \
  --slot staging --target-slot production

# (or cancel and revert the applied config)
az webapp deployment slot swap --action reset \
  --name app-payments-prod --resource-group rg-payments-prod \
  --slot staging --target-slot production

Slot strategy comparison

When to use direct deploy, slot-no-swap, blue-green swap, or swap-with-preview — the decision table:

Strategy Downtime Rollback speed Use for Cost
Direct to default slot Brief (restart) Slow (redeploy) Dev/SIT/QA Lowest
Slot deploy, no swap None (not live) n/a (pre-prod) Staging/UAT/Pre-Prod validation +slot
Blue-green swap Near-zero Instant (swap back) Production +slot
Swap with preview Near-zero Instant + pre-validated Highest-risk prod releases +slot

Azure DevOps Environments, approvals & checks — the gated release

The promotion path is enforced by Azure DevOps Environments. An Environment is a named, first-class object (payments-production, payments-staging, …) that a deployment job targets via environment:. Attaching approvals and checks to an Environment is what makes the gate real: the deployment job pauses until every check passes, and every approval is recorded with who, when and a comment — your audit trail.

The full catalogue of checks this platform uses and what each guards:

Check type What it does Where used Configured by
Approvals Named approvers must click Approve Staging, UAT, Pre-Prod, Production Release managers
Business hours Only proceeds within a time window Production Platform
Exclusive lock One run at a time per environment Production Platform
Invoke REST API Calls an endpoint; pass/fail gates Production (change ticket check) Platform
Azure Function Runs a function as a gate Pre-Prod (load-result check) Platform
Query Azure Monitor Pass only if metric within bounds Production (error-rate guard) Platform/SRE
Evaluate artifact Policy on the artifact (e.g. signed) Production Security
Required template Deploy must use an approved template All Platform
Branch control Only these branches may deploy here Production (main only) Platform

The production gate, concretely

Production for this platform requires: two approvers from the leads group AND one production manager, a business-hours window, an exclusive lock, a branch control restricting it to main, and a REST check that the change ticket is approved. Layered, these make an unauthorised or out-of-window production deploy structurally impossible. Creating the Environment and attaching the gates via the az devops CLI / REST, then Terraform:

# Create the Environment (idempotent) via the REST surface
az devops invoke \
  --area distributedtask --resource environments \
  --route-parameters project="Payments" \
  --http-method POST --in-file env-production.json

The Terraform that attaches the approval and a branch-control check to the Environment (the platform keeps gates in IaC so they cannot be quietly loosened):

resource "azuredevops_environment" "production" {
  project_id = var.project_id
  name       = "payments-production"
}

# Manual approval: leads group + a production manager, must comment
resource "azuredevops_check_approval" "prod" {
  project_id           = var.project_id
  target_resource_id   = azuredevops_environment.production.id
  target_resource_type = "environment"

  requester_can_approve      = false                # the person who queued can't approve
  approvers                  = [data.azuredevops_group.leads.origin_id,
                                data.azuredevops_group.prod_managers.origin_id]
  minimum_required_approvers = 2
  timeout                    = 43200                # minutes (30 days) before it auto-rejects
  instructions               = "Confirm CAB ticket, scan results, and Pre-Prod load sign-off."
}

# Branch control: only main may deploy to production
resource "azuredevops_check_branch_control" "prod" {
  project_id               = var.project_id
  target_resource_id       = azuredevops_environment.production.id
  target_resource_type     = "environment"
  display_name             = "Only main deploys to Production"
  allowed_branches         = "refs/heads/main"
  verify_branch_protection = true                   # branch must itself be policy-protected
}

# Business hours: production deploys only 09:00-17:00 on weekdays
resource "azuredevops_check_business_hours" "prod" {
  project_id           = var.project_id
  target_resource_id   = azuredevops_environment.production.id
  target_resource_type = "environment"
  display_name         = "Production change window"
  time_zone            = "India Standard Time"
  monday    { start_time = "09:00" end_time = "17:00" }
  tuesday   { start_time = "09:00" end_time = "17:00" }
  wednesday { start_time = "09:00" end_time = "17:00" }
  thursday  { start_time = "09:00" end_time = "17:00" }
  friday    { start_time = "09:00" end_time = "17:00" }
}

Approval vs check — they are different gates

A common confusion that leads to a weak gate. The distinction:

Approval Check
Who decides A human clicks Approve An automated condition
Examples Lead sign-off, prod manager Business hours, REST, branch control, Azure Monitor
Records Who/when/comment Pass/fail + the evaluation
Bypassable Only by Environment admins (audited) No (it’s a condition)
Use together Yes — human judgement + machine guardrails Yes

Who approves what

The RACI of the gates, so the right people are on the right Environment and no single person can push to prod alone:

Environment Approver(s) What they’re certifying
Staging QA lead “QA suites passed; ready for prod-like validation”
UAT Business owner “Users accept the change”
Pre-Prod Performance lead “Survives production-scale load”
Production 2× leads + production manager “Authorised, scanned, ticketed, in-window release”

Rollback strategy — the part you rehearse

A delivery system is judged by how fast it can un-ship. This model gives you three rollback mechanisms, in order of speed, and the discipline is to know which applies and to have rehearsed it before the incident. The mechanisms:

Rollback mechanism Speed Reverses what When to use Pre-condition
Slot swap-back Seconds The live app version Bad release just swapped in Prior version still on the staging slot
Redeploy pinned artifact Minutes The app version (slot gone) Staging slot already overwritten Old artifact in feed/pipeline store
Liquibase down-changeset Minutes The database schema Schema change broke the old code Down-changeset authored + tested
Feature-flag kill Instant A behaviour, not a deploy Bad behaviour behind a flag The change shipped flagged-off

Mechanism 1 — swap back (the two-minute rollback)

Because production went live by swapping the green slot in, the previous version is still running on what is now the staging slot. Rolling back is simply swapping again — the production hostname re-points to the prior, still-warm instances. No rebuild, no redeploy, no waiting for an image pull:

# The entire rollback: swap the slots back. The previous version is live in seconds.
az webapp deployment slot swap \
  --name app-payments-prod --resource-group rg-payments-prod \
  --slot staging --target-slot production

Wrap it as a one-button rollback pipeline so on-call does not type commands under pressure — it targets the same gated Environment (so the swap-back is still authorised and recorded), but with an expedited approver set:

# azure-pipelines-rollback.yml — one-click swap-back, still gated + audited
trigger: none
parameters:
  - name: webapp
    type: string
    default: app-payments-prod
pool: vmss-linux
stages:
  - stage: Rollback
    jobs:
      - deployment: swapBack
        environment: payments-production       # same gate; expedited approvers in an incident
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  inputs:
                    azureSubscription: 'sc-payments-prod'
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      az webapp deployment slot swap \
                        --name ${{ parameters.webapp }} --resource-group rg-payments-prod \
                        --slot staging --target-slot production
                - template: steps/notify-datadog.yml@templates
                  parameters: { event: rollback_swap_back }

Mechanism 2 — redeploy a pinned artifact

If the staging slot has since been overwritten (a later release deployed to it), swap-back no longer has the old version to return to. Then you redeploy the previous immutable artifact — which still exists because CI published it and the feed retains it. You do not rebuild; you re-run CD pinned to the prior run’s artifact (or trigger the CD pipeline with the older CI run as the artifact source):

# Re-run the CD pipeline using a specific prior CI run as the artifact source
az pipelines run --name payments-api-cd \
  --branch main \
  --parameters ciRunId=48217        # the run that produced 2.4.0, the last-known-good

This is why immutability matters: rollback-by-redeploy is trustworthy only because the artifact you are redeploying is byte-identical to what shipped before.

Mechanism 3 — the database

App rollback is fast; the database is the trap. If your migration ran forward-only and is not backward-compatible, the old code now meets a schema it cannot use, and swapping back the app makes it worse. Two disciplines defuse this. First, expand/contract migrations: a release that needs a schema change does it in backward-compatible steps (add the new column nullable, deploy code that writes both, backfill, then a later release removes the old) so old and new code both work against the intermediate schema. Second, keep a Liquibase down-changeset ready and tested:

# Roll the database back one changeset (Liquibase), secrets from Key Vault
liquibase --changeLogFile=db/changelog/changelog-master.xml \
  --url="jdbc:postgresql://psql-payments-prod.postgres.database.azure.com:5432/payments" \
  --username="$DB_USER" --password="$DB_PASSWORD" \
  rollbackCount 1

The Liquibase mechanics (changelog prep, update, rollback, secrets from Key Vault) against Azure MySQL/PostgreSQL/MS SQL are covered end to end in Shift-Left Security, Testing, Observability — and Mobile + Database Delivery — on Azure DevOps.

Deciding to roll back — the signal

You roll back on signal, not vibes. Datadog (release tracking) watches the new version’s error rate, latency and saturation against the prior version; the production Environment can even carry a Query Azure Monitor check that fails the deploy if error rate spikes. The decision table on-call uses:

Signal after go-live Threshold Action
5xx error rate vs baseline > 2× for 3 min Swap back
p95 latency vs baseline > 1.5× sustained Swap back
Health-check failures Any instance unhealthy post-swap Swap back
Datadog deployment-tracking regression New version worse on a tracked monitor Swap back
A flagged behaviour misbehaving Any Kill the flag (not a rollback)
Schema-related errors Any Swap back and run down-changeset

Architecture at a glance

Read the system left to right and you can trace any change from a keystroke to production. On the far left, a developer’s feature/* branch opens a pull request into development; the PR pipeline runs on the shared VMSS agent fleet as a pure gate — lint, tests, coverage and Veracode scans — and the branch policy refuses the merge until they are green and a human has approved. The merge advances development (or, later, release/* or main), which triggers the CI pipeline: it pulls secrets from the Key Vault for that scope, restores from the internal Azure Artifacts feed under enforcement, builds one immutable, versioned artifact, runs Veracode SCA plus unit and integration tests, and publishes that artifact. From there the multi-stage CD pipeline takes over and never rebuilds — it promotes that single artifact rightward through the seven environments.

Follow the artifact across the environment band: it deploys directly to Dev, SIT and QA (smoke, integration, then Playwright/WebdriverIO suites), then — on release/* — to the staging slot of the production-shaped App Service for Staging, UAT and Pre-Prod, where an Azure Load Test (JMX) proves it at production scale. Each pre-prod and production stage is fronted by an Azure DevOps Environment carrying approvals and checks; the Production gate demands lead + production-manager sign-off inside a business-hours window. At the right edge, Production go-live is blue-green: the artifact lands on the green staging slot, the platform warms it via the /health/ready warm-up path, and a swap atomically re-points the production hostname — with a swap-back held in reserve as the instant rollback. Datadog watches the release throughout for the error-rate and latency signals that decide whether the new version stays. The numbered badges mark the five moments that most often bite: the merge gate, feed enforcement, the artifact hand-off, the production approval, and the swap itself.

Left-to-right enterprise delivery architecture: a feature branch and PR feeding a VMSS-hosted PR gate; a CI pipeline pulling from Key Vault and the internal Azure Artifacts feed to build one immutable artifact; a multi-stage CD pipeline promoting that artifact through Dev, SIT, QA, then Staging/UAT/Pre-Prod on App Service staging slots with an Azure Load Test, gated by Azure DevOps Environments with lead and production-manager approvals; and a blue-green production go-live via a staging-slot warm-up and swap, observed by Datadog, with swap-back as rollback.

Real-world scenario

The platform: “Meridian Pay”, a regulated payments unit running 40-odd services on Azure App Service behind the shared Azure DevOps platform described above. They came to me after a quarter from hell: two production incidents both rooted in delivery, not code. The first was a Friday “quick fix” merged straight to main that also dragged in a half-finished feature someone had merged earlier the same day — because main was doubling as both the integration and the release branch. The second was a swap that took the payments API down for nine minutes: the green slot had never been warmed, so when it swapped in, every instance cold-started under live traffic and the gateway timed out. Their release manager’s exact words: “I cannot tell you what is in production without SSH-ing into a box.”

We rebuilt the delivery model, not the apps. First, GitFlow with a hard rule: development is the only place features integrate, release/x.y is feature-frozen, main is only ever advanced by a release or hotfix merge, and every production deploy corresponds to a tag. We made main’s branch policy require two reviewers, prohibit self-approval, restrict completion to merge commits, and — the line that mattered most — denied “bypass policies” to everyone, including the leads who had been using it. Second, one artifact: the CI pipeline now builds once from the tagged commit and the CD pipeline promotes that exact zip through all seven environments; we proved it by hashing the artifact at Dev and at Production and showing they matched. Third, blue-green done right: every production release deploys to the staging slot with Always On, the pipeline pings /health/ready until it returns 200 across all instances, and only then swaps. The warm-up step alone eliminated the nine-minute class of incident — the slot is hot before it ever serves a user.

The results over the next two quarters: zero swap-induced downtime (the warm-up gate caught three would-be-cold releases and failed them before the swap), mean time to roll back fell from ~35 minutes to under 2 because rollback became “swap the slots back” instead of “rebuild the previous commit,” and — the one the auditors cared about — every production change now has a named approver, a CAB ticket reference, a version tag, and a scan record, reconstructable from the Environment’s approval history. The one genuine cost was the extra App Service slot per service and the discipline tax of release branches; the payments director signed off on both in about thirty seconds once we put the nine-minute incident’s revenue impact next to the slot’s monthly cost. The DORA metrics moved too — see DORA Metrics & Platform Engineering for how we tracked change-failure-rate and MTTR through this.

Advantages and disadvantages

No model is free. The honest two-column trade-off of this architecture — GitFlow + three pipelines + seven environments + blue-green slots — before the nuance:

Advantages Disadvantages
main/tag is an unambiguous “what’s in prod” More long-lived branches → more merging
Immutable artifact → QA sign-off is meaningful Seven environments cost compute + upkeep
Gated, audited production release (CAB-ready) Approvals add latency to every release
Blue-green swap → near-zero-downtime go-live An extra App Service slot per service ($)
Rollback = swap back (seconds, no rebuild) Slot-settings mistakes can leak config
Hotfix path isolated from the release train GitFlow merge ceremony slows fast teams
Separation of concerns → each pipeline auditable Three pipelines = more YAML to maintain
Feed enforcement closes dependency-confusion Stricter, so “just push it” is impossible

The disadvantages matter most in two situations. If you are a small, high-trust team shipping many times a day, the GitFlow ceremony and the seven environments are pure drag — trunk-based with flags and a lean Dev/Staging/Prod is the better fit. If you are cost-constrained, the per-service extra slot and the pre-prod clone are real money; you can collapse SIT/QA or share a pre-prod, accepting that you catch integration and load issues later. The advantages dominate precisely when the disadvantages hurt least: regulated, batched-release, multi-team, high-stakes-production environments, which is exactly the platform this series targets. The art is not “always do all of this” — it is knowing which controls your risk profile actually requires.

Hands-on lab

This lab builds the blue-green core end to end on a single App Service and proves the swap-and-rollback loop, plus the Environment gate — the two ideas you most need to feel in your hands. It needs Standard tier (S1) for slots, which is cheap and you delete at the end. Replace names as needed.

1. Create a resource group, plan and web app (with a staging slot). Slots require Standard or higher:

az group create -n rg-bg-lab -l centralindia

az appservice plan create -n plan-bg-lab -g rg-bg-lab --sku S1 --is-linux

az webapp create -n app-bg-lab-$RANDOM -g rg-bg-lab --plan plan-bg-lab \
  --runtime "DOTNETCORE:8.0"
APP=$(az webapp list -g rg-bg-lab --query "[0].name" -o tsv)

# Create the green slot
az webapp deployment slot create -n "$APP" -g rg-bg-lab --slot staging

2. Make settings sticky and turn on Always On (so the slot can be warmed).

az webapp config set -n "$APP" -g rg-bg-lab --always-on true
az webapp config set -n "$APP" -g rg-bg-lab --slot staging --always-on true

# A sticky setting that identifies the slot and must NOT travel on swap
az webapp config appsettings set -n "$APP" -g rg-bg-lab --slot production \
  --slot-settings SLOT_NAME=production
az webapp config appsettings set -n "$APP" -g rg-bg-lab --slot staging \
  --slot-settings SLOT_NAME=staging

3. Deploy “v1” to production and “v2” to the staging slot. Use a trivial difference so you can see the swap. (In a real pipeline this is the immutable artifact; here we just set a setting that the app echoes, or deploy two zips.)

# A non-sticky setting → it WILL travel on swap, so it marks the live version
az webapp config appsettings set -n "$APP" -g rg-bg-lab --slot production --settings APP_VERSION=v1
az webapp config appsettings set -n "$APP" -g rg-bg-lab --slot staging   --settings APP_VERSION=v2

4. Warm and validate the green slot before swapping. Confirm it responds:

SLOT_HOST=$(az webapp show -n "$APP" -g rg-bg-lab --slot staging --query defaultHostName -o tsv)
curl -s -o /dev/null -w "staging slot → HTTP %{http_code}\n" "https://$SLOT_HOST"

5. Swap — go live. The production hostname now serves what was the staging slot:

az webapp deployment slot swap -n "$APP" -g rg-bg-lab --slot staging --target-slot production

# APP_VERSION travelled (v2 is now live); SLOT_NAME did NOT (prod still says production)
az webapp config appsettings list -n "$APP" -g rg-bg-lab --slot production \
  --query "[?name=='APP_VERSION' || name=='SLOT_NAME']" -o table

6. Roll back — swap again. This is the entire rollback. The previous version is live in seconds, no rebuild:

az webapp deployment slot swap -n "$APP" -g rg-bg-lab --slot staging --target-slot production
# APP_VERSION is back to v1; you just rolled back by swapping.

7. Add an Environment gate (in Azure DevOps). Create an Environment and require approval, then target it from a one-stage pipeline so you watch the deploy pause for approval:

az pipelines create --name bg-lab-cd --repository <your-repo> --branch main \
  --yml-path azure-pipelines-bg-lab.yml --skip-first-run true
# In Pipelines → Environments → New 'bg-lab-prod' → Approvals and checks → add yourself as approver.
# azure-pipelines-bg-lab.yml — minimal gated deploy
trigger: none
pool: vmss-linux
stages:
  - stage: Production
    jobs:
      - deployment: deploy
        environment: bg-lab-prod        # ← will pause here for your approval
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  inputs:
                    azureSubscription: '<service-connection>'
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      az webapp deployment slot swap -n "$APP" -g rg-bg-lab \
                        --slot staging --target-slot production
                  env: { APP: app-bg-lab-xxxx }

8. Teardown. Delete everything so the slot/plan stops billing:

az group delete -n rg-bg-lab --yes --no-wait

What you proved: a slot is a real running copy; a swap is atomic and re-points the hostname; sticky settings stay, non-sticky travel; rollback is just swapping back; and an Environment makes a deploy wait for a human. That is the production model in miniature.

Common mistakes & troubleshooting

The failure modes that actually page you, with the exact signal, the command or blade that confirms it, and the fix. This is the differentiator — scan the table, then read the detail for your row.

# Symptom Root cause Confirm (exact command / path) Fix
1 Swap “succeeds” but the site is slow for minutes Green slot was cold; no warm-up App Insights request duration spikes post-swap Always On + /health/ready warm-up gate before swap
2 After swap, prod points at the wrong database Connection string not marked slot-sticky az webapp config connection-string list — not under slotSetting Mark it --slot-settings; re-verify sticky_settings
3 Production deployed from a feature branch No branch control on the Environment Environment → Approvals and checks shows no branch control Add azuredevops_check_branch_control = refs/heads/main
4 “It worked in QA but broke in prod” Per-environment rebuild (different artifact) Hash artifact in QA vs Prod — they differ Build once in CI; CD downloads the same artifact
5 Anyone can complete a PR despite policy “Bypass policies” granted broadly Repo → Security → “Bypass policies when completing PRs” = Allow Deny it to all; grant to a tiny break-glass group only
6 CD ran but skipped Production Stage condition branch mismatch Pipeline run → Production stage “skipped” Fix condition: eq(... 'refs/heads/main'); check trigger branch
7 Approval never appears; deploy just runs deployment job not bound to the Environment YAML shows a job: not deployment: … environment: Use a deployment job with environment:
8 Restore pulls a public package despite “internal only” nuget.config lacks <clear/>; lock not enforced Build log shows nuget.org URL; no --locked-mode <clear/> first; --locked-mode / lockfile
9 Hotfix fixed prod but the bug came back next release Hotfix never merged back to development git log development lacks the hotfix commit Always back-merge maindevelopment
10 Swap-back doesn’t roll back the schema DB migration ran forward-only App rolled back but schema is v2 Expand/contract migrations + Liquibase down-changeset
11 Two prod deploys collide / race No exclusive lock on the Environment Two runs in the Production stage simultaneously Add the exclusive-lock check
12 Secrets visible in pipeline logs Secret echoed / not a secret variable Pipeline log prints the value Use Key Vault-backed group; never echo secrets
13 CD didn’t trigger after CI Pipeline-resource trigger branch filter wrong CD run history empty after a CI run Match pipelines.trigger.branches to the CI source branch

1 — The cold-slot swap (the nine-minute outage)

The most expensive blue-green mistake. You swap a green slot that has never received a request, so every instance cold-starts under full production load — runtime boot, JIT, pool prime — and the gateway times out while they warm. Confirm: Application Insights shows request duration spiking immediately after the swap timestamp, with a burst of 502/504. Fix: turn on Always On for the slot and gate the swap behind a warm-up that pings /health/ready until every instance returns 200 (the warmup-slot.yml step above). The swap then routes to already-warm instances. Never swap a slot you have not warmed. The cold-start mechanics underneath are dissected in Troubleshooting Azure App Service: 502/503 Errors, Cold Starts & Restart Loops.

2 — Config that travelled when it shouldn’t have

After a swap, production starts talking to the staging database, or runs as ASPNETCORE_ENVIRONMENT=Staging, because those settings were not marked slot-sticky and so travelled with the app. Confirm: az webapp config appsettings list --slot production shows the value that belongs to staging; the connection-string list shows slotSetting: false on something that identifies the environment. Fix: mark every environment-identifying setting --slot-settings (or sticky_settings in Terraform), then swap-test in a lab to confirm the right values stay put. The rule: environment identity is sticky; application behaviour travels.

3 — Production deployable from any branch

If the Production Environment has no branch control, a misconfigured pipeline (or a person) can deploy to it from development or a feature branch — the gate you thought you had does not exist. Confirm: Environment → Approvals and checks shows no branch-control entry. Fix: add the branch-control check restricting to refs/heads/main with verify_branch_protection = true, so only the policy-protected main can reach production.

4 — Different bytes in QA and prod

The subtlest and most dangerous. A pipeline that rebuilds in each stage means QA approved one artifact and production runs another — a newer transitive dependency resolved, a moved feed package, a different compiler run. Confirm: compute the artifact’s hash at the QA deploy and the Production deploy (sha256sum the downloaded zip) — if they differ, you are rebuilding. Fix: build exactly once in CI, publish a pipeline artifact, and have every CD stage download that same artifact. The hash must be identical end to end.

5 — The bypassable policy

A branch policy that privileged users can bypass is not a control. Confirm: Repo → Settings → Security, check “Bypass policies when completing pull requests” and “Bypass policies when pushing” — if these are Allow for any broad group, the gate is theatre. Fix: set both to Deny for everyone, and create a tiny, audited break-glass group for genuine emergencies (with an alert when it is used).

6 — The silently-skipped Production stage

The pipeline goes green but Production never ran — the stage condition did not match the trigger branch, so it was skipped (not failed), which is easy to miss. Confirm: the run’s stage view shows Production as Skipped. Fix: verify the condition (e.g. eq(variables['resources.pipeline.ci.sourceBranch'], 'refs/heads/main')) matches how the CD pipeline was actually triggered, and that the CI run that triggered it was on main.

7 — The approval that never shows

You added an approval to the Environment but the deploy ran straight through without pausing. Almost always the job is a plain job:, not a deployment: job bound to the Environment — approvals only fire on deployment jobs that declare environment:. Confirm: the YAML for the stage uses - job: (wrong) instead of - deployment: with environment: (right). Fix: convert it to a deployment job targeting the Environment.

8 — The feed-enforcement leak

“Internal feed only” is only true if the config forbids public sources and the lockfile is enforced. A nuget.config without <clear/> still inherits nuget.org; a restore without locked mode silently pulls new transitives. Confirm: the build log shows a https://api.nuget.org/... URL, or packages.lock.json changed during restore. Fix: put <clear/> first in <packageSources>, list only the internal feed, and restore with --locked-mode (or the equivalent lockfile gate for npm/Maven/pip). The feed setup and enforcement detail live in Centralized Azure Pipeline YAML Templates + Azure Artifacts Feeds.

9 — The hotfix that wasn’t back-merged

A hotfix shipped to production via hotfix/2.4.1main but was never merged back into development, so the next release from development reintroduces the bug. Confirm: git log development --oneline | grep <hotfix-commit> returns nothing. Fix: make back-merge mandatory — maindevelopment after every hotfix and every release — ideally enforced by an automation that opens the back-merge PR.

10 — Rollback that doesn’t roll back the database

You swap back to roll the app back, but the database migration ran forward-only, so the old code now meets a schema it does not understand. Confirm: the app is on the previous version but queries fail with column/constraint errors; the schema is at the new version. Fix: use expand/contract (backward-compatible) migrations so old and new code both work against the intermediate schema, and keep a Liquibase down-changeset ready. Database rollback is covered in depth in Shift-Left Security, Testing, Observability — and Mobile + Database Delivery — on Azure DevOps.

11 — Colliding production deploys

Two pipeline runs hit the Production stage at once (a re-run plus the original, or two merges), and they race on the swap. Confirm: the Environment shows two concurrent runs in the Production stage. Fix: add the exclusive lock check to the Production Environment so only one run proceeds and the other queues.

12 — Secrets in the logs

A script step echoes a value that came from a secret, and now it is in the (retained, possibly widely-readable) pipeline log. Confirm: open the run log and search for the value. Fix: source secrets from a Key Vault-backed variable group (which masks them), never echo/Write-Host them, and rotate anything that leaked. See Secret Management in Pipelines with Key Vault & Managed Identity.

13 — CD that never triggers after CI

You merged, CI ran green, but the CD pipeline never started. The pipeline-resource trigger in the CD YAML did not match the branch the CI run was on. Confirm: the CD pipeline’s run history is empty after the CI run completed. Fix: ensure the CD pipeline’s resources.pipelines.trigger.branches includes the branch CI ran on (development/release/*/main), and that the CI pipeline name in source: is exact.

Best practices

The rules a senior engineer enforces on this platform, distilled:

# Practice Why it matters
1 Build the artifact once; deploy it everywhere QA sign-off is meaningless if prod runs different bytes
2 main is the only production source of truth One-word answer to “what’s in prod”
3 Every production release has a semantic tag Immutable, human-meaningful, reconstructable
4 Deny “bypass policies” to everyone; tiny break-glass group A bypassable gate is no gate
5 Never swap a slot you haven’t warmed and validated Cold swap = the nine-minute outage
6 Mark every environment-identifying setting slot-sticky Stops prod config from travelling on swap
7 Bind production deploys to main via branch control Closes the “deploy from anywhere” hole
8 Use deployment jobs + Environments for every gated stage Approvals only fire on deployment jobs
9 Always back-merge maindevelopment Hotfixes and release fixes don’t regress
10 Rollback = swap back; keep the prior artifact pinned Seconds, not a rebuild, during an incident
11 Restore from the internal feed with a lockfile Closes dependency confusion
12 Keep gates and policies in IaC, not just the UI They can’t be quietly loosened
13 Expand/contract DB migrations + a tested down-changeset App rollback without a schema mismatch
14 Run every job on the shared VMSS pool, not hosted Hub network access, private endpoints, scale
15 Pin template versions (@templates at a tag) A template change can’t silently alter prod

Security notes

Delivery is an attack surface, and this model is built to shrink it. Least privilege per environment class: Non-Prod and Prod use separate service connections mapped to separate managed identities with access only to their own Key Vault scope — a compromised Non-Prod pipeline cannot read Prod secrets. No plaintext secrets, ever: every secret enters via a Key Vault-backed variable group or the AzureKeyVault@2 task, resolved at runtime via managed identity; the pipeline definition (in Git, widely readable) contains references, never values. Internal-feed enforcement is a supply-chain control: restoring only from the curated org feed, with a lockfile, blocks dependency-confusion and unvetted transitive code. Veracode at three points — PR (fast pipeline/SCA/container), CI (SCA on the built artifact), CD (policy + container scans before prod) — means vulnerable code is gated at merge, at build and at release. The production gate is an authorisation control: branch control limits the source to main, approvals require named leads plus a production manager, and the business-hours window plus exclusive lock prevent rushed or racing deploys — all recorded for audit. Finally, the agents live in the hub network behind private endpoints, so builds reach PaaS over the private network, not the public internet. For the identity mechanics underneath, see Secret Management in Pipelines with Key Vault & Managed Identity and, for the OIDC alternative to long-lived credentials, GitHub Actions → Azure with OIDC Federated Credentials.

Cost & sizing

What actually drives the bill in this delivery model, and how to right-size it. The four cost centres:

Cost centre What drives it Rough scale How to right-size
App Service plans (×7 envs) SKU × instances × envs Biggest line Lower SKUs for Dev/SIT/QA; prod-grade only Staging/Pre-Prod/Prod
Deployment slots One extra slot per app (Standard+) +1 slot/app Slot shares the plan — no extra plan, but needs S1+
VMSS agents Agent VM size × count × hours Scales with concurrency Autoscale to zero off-hours; right-size the VM SKU
Azure DevOps Parallel jobs + user licences Per parallel job/user Buy parallelism to match peak concurrency, not headcount

The slot subtlety drives most “surprise” cost questions: a deployment slot does not need its own plan — it runs on the same App Service plan as the production app, sharing its compute. The cost of blue-green is therefore not a second environment; it is the requirement to be on Standard (S1) or higher (Basic and Free do not support slots), which for a small prod app is on the order of a few thousand INR/month. The seven environments are the real spend, and the lever is fidelity tiering: Dev/SIT/QA on B-tier or small S1; Staging, Pre-Prod and Production on the production SKU because their whole job is to be production-like. The VMSS agents bill for VM time, so autoscaling the scale set down (even to zero) outside working hours is the single biggest agent saving. On Azure DevOps itself, the cost is parallel jobs (self-hosted parallelism is far cheaper than Microsoft-hosted) and licences — buy enough parallel jobs to clear your peak queue, not one per developer. As a rough order of magnitude for a single service across all seven environments with shared agents: the slots and lower environments are modest; the production-grade Staging/Pre-Prod/Prod plans dominate, which is exactly why fidelity tiering is where you optimise. For App Service tier specifics see Azure App Service Plans & Tiers Explained: Free to Isolated.

Interview & exam questions

Twelve questions a senior platform/DevOps role (and the AZ-400 Designing and Implementing Microsoft DevOps Solutions exam) will probe, with model answers.

Q1. Why must the CI pipeline build the artifact exactly once? So the bytes tested in QA are the literal bytes that reach production. If each environment rebuilds, a newer transitive dependency or moved feed package can change the artifact, invalidating every downstream sign-off. Build once, publish a pipeline artifact, and have every CD stage deploy that same artifact — provable by an identical hash end to end. (AZ-400: artifact management.)

Q2. What does an App Service slot swap actually do, and why is it near-zero-downtime? The swap applies the target slot’s app settings to the source slot’s instances, restarts and warms them, waits until they respond, then atomically re-points the production hostname’s routing to those now-warm instances; the old instances become the staging slot. Because routing only switches after the instances are responsive, users see no cold start — provided you also warmed the slot beforehand.

Q3. Which app settings should be marked as slot (sticky) settings, and why? Anything that identifies the environmentASPNETCORE_ENVIRONMENT, the database connection string, the telemetry/DD_ENV config, the Key Vault URI. These must not travel on swap or production would inherit staging’s config. Settings that are the release behaviour (the code/version, feature-flag values) should travel.

Q4. How do you make a branch policy genuinely un-bypassable? Require reviewers (min count), prohibit the author/last-pusher from approving, reset approvals on push, require build validation and external status checks — and critically, deny the “Bypass policies when completing/pushing” permission to everyone, reserving it for a small audited break-glass group. Without the last step the rest is advisory.

Q5. Why use a deployment job rather than a regular job in CD? A deployment job binds to an Environment, which is what triggers approvals and checks, records deployment history per environment, and supports strategies (runOnce, canary, rolling) with lifecycle hooks. A plain job has no Environment, so no approvals fire — a common reason a gate “doesn’t work.”

Q6. Walk me through a two-minute production rollback in this model. Production was swapped from the staging slot, so the previous version is still running on what is now the staging slot. Rolling back is az webapp deployment slot swap --slot staging --target-slot production again — re-pointing the hostname to the prior instances in seconds, no rebuild. The only caveat is the database: forward-only migrations break this, so use expand/contract plus a Liquibase down-changeset.

Q7. What is the difference between an approval and a check on an Environment? An approval is a human clicking Approve (recorded with who/when/comment); a check is an automated condition — business hours, branch control, an Invoke-REST/Azure-Function gate, an Azure Monitor query. Approvals capture judgement; checks capture guardrails. Production uses both: lead/manager approvals and branch-control + business-hours + exclusive-lock checks.

Q8. Why seven environments — isn’t Dev/Staging/Prod enough? Each catches a distinct risk: Dev (does it deploy), SIT (do services integrate), QA (do suites pass), Staging (prod-like infra/config), UAT (do users accept), Pre-Prod (survives prod-scale load + real network), Production. Collapsing them is legitimate for lower-risk apps, but a regulated, high-stakes platform wants each risk class caught before the one after it.

Q9. How does GitFlow keep a hotfix from corrupting the release train? The hotfix branches off main (production), not the integration branch, so it carries only the fix — no half-done features ride along. It ships to prod via the same gate, gets a patch tag, then is merged back into both main and development. The release branch in flight is untouched.

Q10. What is “swap with preview” and when would you use it? A two-phase swap: phase one applies the production slot settings to the staging instances (so they run production config) without changing routing, letting you validate; phase two completes the swap (or you reset). Use it for the highest-risk releases where you must verify the app under production configuration before committing traffic.

Q11. How do you enforce that builds only use vetted dependencies? Restore exclusively from the internal Azure Artifacts feed (a curated proxy of upstreams), with the package-source config clearing public sources (<clear/>) so nothing leaks in, and a lockfile enforced (--locked-mode / packages.lock.json) so no new or transitive package sneaks in silently. This blocks dependency-confusion attacks.

Q12. Where do Veracode scans run in this model, and why three places? PR (fast Pipeline Scan + SCA + container, gating the merge), CI (SCA on the actually-built artifact), and CD (Policy scan + container scan before production). Three points because each catches a different window: at merge (cheap, on the diff), at build (the real artifact and its resolved deps), and at release (the final policy gate before prod).

Quick check

  1. Your QA team approved build 2.4.0-rc.3, but production is throwing an error QA never saw. What is the first thing you check about the artifact?
  2. After a production swap, the app starts using the staging database. Which single setting property was wrong?
  3. You added an approval to the payments-production Environment, but deploys run straight through. What is the most likely YAML mistake?
  4. A hotfix fixed production last week; this week’s release reintroduced the same bug. What step was skipped?
  5. Production needs to be rolled back now. What is the command, and what is the one thing that can stop it from fully rolling back?

Answers

  1. That it is the same bytes. Hash the artifact deployed to QA and the one deployed to Production; if they differ, your pipeline is rebuilding per environment instead of promoting one immutable artifact. Fix: build once in CI, download the same artifact in every CD stage.
  2. The connection string was not marked as a slot (sticky) setting, so it travelled with the app on swap. Mark it --slot-settings (Terraform sticky_settings) so environment-identifying config stays pinned to its slot.
  3. The stage uses a plain job: instead of a deployment: job bound to environment: payments-production. Approvals/checks only fire on deployment jobs that target an Environment.
  4. The back-merge of maindevelopment after the hotfix. Without it, development never received the fix, so the next release built from it reintroduced the bug. Make back-merge mandatory.
  5. az webapp deployment slot swap --slot staging --target-slot production (swap back to the prior instances). The thing that can stop a full rollback is a forward-only database migration — if the schema moved and is not backward-compatible, the old code meets a schema it cannot use; expand/contract migrations and a Liquibase down-changeset prevent this.

Glossary

Term Definition
GitFlow A branching model with long-lived development and main, short-lived feature/*, release/* and hotfix/* branches, suited to scheduled, gated releases.
development The permanent integration branch where features merge; source for the Dev/SIT/QA stages.
release/x.y A short-lived, feature-frozen branch where a version is stabilised; source for Staging/UAT/Pre-Prod.
main The production source of truth; only advanced by release/hotfix merges and tagged with the shipped version.
hotfix/* An urgent fix branched off main, fast-tracked to production, then back-merged to main and development.
Semantic version tag An annotated Git tag MAJOR.MINOR.PATCH naming the exact commit a release was built from.
Branch policy Azure DevOps rules attached to a branch (reviewers, build validation, status checks, merge type) that block PR completion until satisfied.
PR pipeline The pre-merge gate: lint, analysis, unit tests, coverage and Veracode scans; never deploys.
CI pipeline Builds one immutable, versioned artifact from development/release/main: Key Vault secrets, feed restore with enforcement, build, scan, test, publish.
CD pipeline The multi-stage pipeline that promotes the one artifact through the seven environments to production.
Pipeline artifact The immutable build output published by CI and downloaded unchanged by every CD stage.
Environment (Azure DevOps) A first-class deploy-target object that deployment jobs bind to, carrying approvals, checks and deployment history.
Approval A gate requiring a named human to click Approve; recorded with who/when/comment.
Check An automated gate condition on an Environment (business hours, branch control, REST/Function, Azure Monitor query, exclusive lock).
Deployment slot A fully-functioning, swappable copy of an App Service on the same plan, with its own hostname; the blue-green “green.”
Swap Re-pointing the production hostname’s routing to a (warmed) slot’s instances; the go-live action and the instant rollback.
Slot (sticky) setting An app setting/connection string pinned to a slot that does not travel on swap.
Swap with preview A two-phase swap that applies target config to the source instances for validation before completing the route switch.
Variable group A named set of pipeline variables (optionally Key Vault-backed) supplying per-environment configuration.
Service connection The credential (managed-identity-backed) Azure DevOps uses to act against an Azure subscription.
Expand/contract migration A backward-compatible schema change that lets old and new code run against an intermediate schema, enabling app rollback.

Next steps

Azure DevOpsGitFlowCI/CDBlue-GreenDeployment SlotsBranch PoliciesMulti-Stage PipelinesRelease Management
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading