DevOps CI/CD Security & Delivery

Shift-Left Security, Testing, Observability — and Mobile + Database Delivery — on Azure DevOps

A pipeline that compiles and deploys is table stakes. The pipeline that earns its keep is the one that refuses to ship — the one that blocks a merge because a transitive dependency carries a known-exploited CVE, fails a promotion because the p95 under load crossed your budget, holds a production release until two leads click approve, and the moment the swap completes drops a deployment marker into your observability tool so the next regression is pinned to that version in seconds. Everything between “git push” and “users are served” is a series of gates, and a senior platform team’s real product is the placement and honesty of those gates. This article is about the security, testing and observability gates on an enterprise Azure DevOps platform — and then about the two delivery surfaces that always get bolted on last and break in production first: mobile (Android + iOS, signing and the store) and database (schema versioning that has to roll forward and back without losing a row).

The reference platform is a single Azure DevOps organisation carved into purpose-built projects — an IaC project (Terraform modules, one Git repo per module), a templates project (centralised reusable YAML), a packages project hosting Azure Artifacts feeds (Maven, NuGet, npm, Python), and many application projects that consume all three — with a centralised self-hosted agent fleet on a VM Scale Set in the hub network running every job elastically. Pipelines come in three shapes: a PR pipeline that gates merges, a CI pipeline that fetches secrets, restores from the internal feed, builds and tests, and a CD pipeline that promotes an artifact through Dev → SIT → QA → Staging → UAT → Pre-Prod → Production with a gated production release. Veracode scans sit at every layer; Datadog watches the result. We wire all of that concretely — real azure-pipelines.yml, az/az pipelines CLI, Terraform, and Veracode/Datadog/Liquibase/Applivery mechanics — and never invent a product feature.

This is the part of the six-blog series where the pipeline stops being plumbing and becomes a control plane: Veracode scans placed and tuned to block real risk without crying wolf, secrets fetched from Key Vault over OIDC so nothing reusable is ever stored, a testing layer that genuinely gates promotion, Datadog wired to pin every regression to a release — then the two awkward surfaces, mobile and database, shipped with the same rigour.

What problem this solves

Without disciplined gates, “DevOps” degrades into “deploy faster,” and deploying broken things faster is just a faster outage. The failure modes are specific and they compound. A developer adds left-pad-shaped transitive dependency with a critical CVE and nobody notices until a customer’s scanner does. A secret gets pasted into a YAML variable “just for now” and leaks in a build log six months later. A release that “passed all tests” had no load test, and the first flash-sale of real traffic exhausts the connection pool. A mobile build ships unsigned, or signed with the wrong provisioning profile, and the App Store rejects it the night before launch. A schema migration runs forward fine, then a hotfix needs to be rolled back and there is no down-path, so an engineer hand-edits production tables at 03:00. Each of these is preventable by a gate that should have existed.

The deeper problem is where the gate lives. Security scanning that only runs in CI (after merge) lets bad code into the trunk; it has to shift left to the PR. Secrets fetched with a stored Personal Access Token (PAT) are a credential waiting to leak; they move to federated OIDC so there is no secret to steal. A test suite whose failures get retried until green is not a gate, it is theatre. Observability bolted on after an incident can’t tell you which release caused it; the deployment marker has to be emitted by the pipeline at swap time. And mobile and database get treated as side-quests when they are first-class delivery surfaces with their own unforgiving gates — code signing and irreversible DDL.

This bites every enterprise running more than a handful of services on Azure DevOps, and it bites hardest at the seams — the PR→CI→CD handoffs, the secret-fetch step, the mobile signing step, the database migration step — because that’s where a missing or dishonest gate does the most damage. The fix is never “add more pipelines”; it is “put the right gate at the right hop and make it fail closed.”

To frame the whole field before the deep dive, here is the gate map: each control, the pipeline stage it belongs in, what it blocks, and the single signal that tells you it fired.

Gate Lives in Blocks Fails closed on First signal it fired
Veracode SCA PR + CI Known-vuln / bad-licence dependencies Policy-breaking CVE or licence Scan summary lists a failing component
Veracode Pipeline Scan PR New flaws in changed code (fast SAST) New finding above severity floor pipeline-scan exit code ≠ 0
Veracode Policy Scan CD (pre-Prod) A build that violates the org policy Any open policy-affecting flaw Policy compliance = “Did Not Pass”
Veracode Container Scan PR + CD Vulnerable base image / OS packages CVE above threshold in the image Image scan flags a CVE class
Key Vault via OIDC CI + CD A build with no/leaked secrets Wrong federated subject or RBAC AADSTS700213/403 on the KV task
Internal-feed enforcement CI Packages from public registries Restore source not the feed “package not found” from the feed
Test layer (load/UI/API) CD per stage A regression in latency/UX/contract A threshold/criteria breach Stage fails on a test gate
Production approval CD (Prod) An unauthorised release Missing lead/manager sign-off Stage waits on a “checks” gate
Datadog deploy marker CD (post-swap) (Detective, not preventive) n/a — it records Deployment overlay on the dashboard
Mobile signing Mobile CI An unsigned/mis-signed binary Missing cert/profile “No signing certificate” / profile error
Liquibase changeset DB pipeline An ad-hoc/irreversible schema change Checksum drift / failed precondition validationFailed or checksum error

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand the platform’s spine: the multi-project Azure DevOps org, the centralised scale-set agents, the GitFlow branching model (feature/* → development → release/* → main, plus hotfix/*) and semantic version tags, and the three pipeline shapes (PR, CI, CD). You should be comfortable reading YAML pipelines with stages/jobs/steps and extends against centralised templates, running az and az pipelines in a shell, and the basics of App Service deployment slots. If any of that is shaky, read the upstream pieces first: The CI/CD Pipeline Explained, Git Branching Strategies Explained, and Azure DevOps Artifacts Feeds, Upstream Sources & Versioning.

This article is the security-and-delivery half of the series. The structural half — projects, modules, templates, agents, the IaC and landing-zone plumbing — lives in its companion pieces; here we assume the pipelines exist and focus on the gates inside them and the surfaces they ship to. It pairs tightly with Shift-Left Testing & Quality Gates (the testing philosophy), Pipeline Secrets Management and Workload Identity Federation for Secretless CI/CD (the OIDC mechanics), and Deployment Strategies: Blue-Green, Canary & Rolling (the slot-swap pattern). The supply-chain angle deepens in Software Supply Chain: SBOM, VEX & Admission Verification.

A quick map of who owns each gate, so you route an alert to the right team during an incident:

Layer What lives here Who usually owns it What breaks if it’s wrong
PR pipeline Lint, SAST, SCA, container scan, review App team + AppSec Bad code/deps enter the trunk
CI pipeline Secret fetch, feed restore, build, tests, publish App team + Platform Unsigned/insecure artifacts
Service connection OIDC federation to Entra Platform / Identity Secret fetch fails or over-permits
Key Vault (per scope) Secrets, certs, keys Platform + AppSec Boot-time/connect-time failures
Artifacts feed Maven/NuGet/npm/Python packages Packages team Supply-chain poisoning
CD pipeline Promotion, tests, swap, approvals App team + Release mgmt Wrong thing reaches prod
Datadog Deploy markers, APM, SLOs, monitors SRE / Observability Blind to which release broke
Mobile pipeline Signing, versioning, store distribution Mobile team + Platform Store rejection / leaked cert
Database pipeline Liquibase changelogs, rollback App team + DBA Irreversible/lost-data migration

Core concepts

Six mental models make every later decision obvious.

A gate is only a gate if it fails closed. A scan that warns but doesn’t break the build, a test whose failures are retried until green, an approval that everyone rubber-stamps — these are decorations, not controls. The defining property of a real gate is that the default outcome of “something is wrong” is “the pipeline stops.” Throughout this article, the question for every step is: does a failure here actually block the merge / promotion / release, or does it just log? If it only logs, it is not protecting you.

Shift-left means earlier, not more. The cheapest place to catch a vulnerability is the PR, before the bad code is in the trunk and before anyone has built on top of it. So the fast checks — SAST on the diff, SCA on dependencies, a container scan on the image — run in the PR pipeline as merge gates. The slower, authoritative checks — a full Veracode Policy Scan against the org policy — run later, in CD before production, where a few extra minutes are affordable. You do not run every scan everywhere; you run the right scan at the right speed/cost point.

Secrets are fetched, never stored. The platform’s secret model is: secrets live in Key Vault (one per landing-zone scope), and pipelines reach them at run time through a variable group that is linked to the vault, authenticated by a service connection using workload-identity federation (OIDC). There is no PAT, no client secret, no password = "..." anywhere — Azure DevOps presents a short-lived federated token to Entra ID, Entra trusts the pipeline’s identity, and Key Vault RBAC authorises the read. A leaked build log contains nothing reusable.

The artifact is immutable and the feed is the only source. CI produces one versioned artifact (a zip, a NuGet package, a container image tagged with the semantic version) and that same artifact is promoted through every environment — you never rebuild per stage. Its inputs come only from the internal Azure Artifacts feed with public registries blocked, so a typosquatted public package can never enter the build. Provenance flows from “what we built” to “what we shipped” with no gap.

Testing is a layered gate, not a phase. Different tests answer different questions and belong at different stages: a fast unit/integration suite in CI proves the code is internally correct; WebdriverIO API tests prove the contract holds; Playwright UI tests prove the user journey works; Azure Load Test driving a JMX plan proves it survives load. A promotion is blocked when the relevant test for that stage breaches its criteria. “All tests passed” is meaningless unless you can name which tests and what they asserted.

Observability is wired by the pipeline, at the moment of change. The single most useful observability signal is “a deployment happened, here, at this version.” The CD pipeline emits that to Datadog as a deployment event the instant the slot swaps, and tags APM traces and monitors with the version. When a monitor or SLO burn-rate alert fires twelve minutes later, the deployment overlay shows it started right after release 2.4.0 — and the rollback is a swap-back, not an investigation.

The vocabulary in one table

Pin down every moving part before the deep sections; the glossary repeats these for lookup.

Concept One-line definition Where it lives Why it matters here
PR pipeline Pipeline that gates a merge Triggered by PR to develop/main Where shift-left scans block bad code
CI pipeline Build + test + publish artifact Triggered on merge / tag Fetches secrets, restores feed, scans
CD pipeline Promotes one artifact through envs Triggered after CI Test gates, swap, approvals, markers
Veracode SCA Software Composition Analysis PR + CI step Finds vulnerable/bad-licence deps
Veracode Pipeline Scan Fast SAST on the changed code PR step Blocks new flaws pre-merge
Veracode Policy Scan Full scan vs the org policy CD pre-Prod step Authoritative pass/fail
Service connection (OIDC) Federated identity to Entra/Azure Project settings Secretless auth to Azure/KV
Variable group (KV-linked) Group of vars sourced from a vault Library Pulls secrets at run time
Azure Artifacts feed Private package registry Packages project Internal-feed enforcement
Azure Load Test Managed JMeter load runner Azure resource + CD step Load gate with pass/fail criteria
Playwright / WebdriverIO UI / API test frameworks CD test stages Journey + contract gates
Deployment slot Swappable copy of an App Service On the App Service plan Blue-green via swap
Datadog deployment event A “deploy happened” marker Posted by CD Pins regressions to a release
Applivery Mobile app distribution platform Mobile CD target Internal/beta app distribution
Liquibase changelog Ordered set of DB changesets In the app repo Versioned, reversible schema

Shift-left security: wiring Veracode into PR, CI and CD

Veracode is not one scan; it is a family, and each member answers a different question at a different cost. Putting the right one at the right hop is the whole skill. Here is the family, mapped to the question it answers, the stage it belongs in, and what “fail” means.

Veracode scan Question it answers Speed Belongs in A “fail” means
Pipeline Scan (SAST) “Did this diff introduce a code flaw?” Seconds–minutes PR (merge gate) New flaw at/above your severity floor
SCA (Agent / upload) “Do our dependencies carry known CVEs / bad licences?” Fast PR + CI A component violates the SCA policy
Static (Policy) Scan “Does the whole build meet the org policy?” Minutes–hours CD, pre-Production Policy compliance = Did Not Pass
Container / image scan “Is the base image / OS layer vulnerable?” Minutes PR + CD CVE above threshold in the image
SCA SBOM export “What exactly is in this artifact?” Fast CI (publish) (Record, not a gate)

Pipeline Scan as the PR merge gate

The Veracode Pipeline Scan is a fast static analysis designed for exactly this: run it on the pull request, fail the build on new findings above a severity you choose, and the developer fixes it before merge. The key tuning levers are --fail_on_severity (the floor that breaks the build) and a baseline file so pre-existing findings don’t block today’s unrelated change — you gate on new flaws, not the historical backlog.

# PR pipeline — Veracode Pipeline Scan as a merge gate (runs on the scale-set fleet)
trigger: none
pr:
  branches: { include: [ development, main ] }
  paths: { exclude: [ docs/*, '**/*.md' ] }

pool: { name: 'selfhosted-vmss-linux' }   # centralised hub agent fleet

steps:
  - script: |
      mvn -q -s $(npmrcPath) package -DskipTests   # build the scan target from the internal feed
    displayName: 'Build artifact to scan'

  - script: |
      curl -sSO https://downloads.veracode.com/securityscan/pipeline-scan-LATEST.zip
      unzip -q pipeline-scan-LATEST.zip pipeline-scan.jar
      java -jar pipeline-scan.jar \
        --veracode_api_id "$(VERACODE_API_ID)" \
        --veracode_api_key "$(VERACODE_API_KEY)" \
        --file target/app.jar \
        --fail_on_severity "Very High, High" \
        --baseline_file veracode-baseline.json \
        --json_output_file results.json
    displayName: 'Veracode Pipeline Scan (fail on High+)'
    # non-zero exit on a failing finding → the PR is blocked

The --fail_on_severity choice is a policy decision, not a default. Too strict (everything) drowns the team in noise and they start ||true-ing the step — the worst outcome, a disabled gate that looks enabled; too loose lets real flaws through. The pragmatic floor for a PR gate is Very High + High, with Medium reported but non-blocking, and a baseline so the backlog is paid down deliberately rather than blocking unrelated work.

Lever What it controls Recommended PR setting Trade-off if wrong
--fail_on_severity Severity floor that breaks the build "Very High, High" Too strict → `
--baseline_file Findings to ignore (pre-existing) A committed baseline No baseline → backlog blocks every PR
--fail_on_cwe Break on specific CWE ids Your “never-merge” CWEs Over-listing re-creates the noise problem
--issue_details Verbose finding output true in PR logs Verbose logs; fine for a gate
Scan target The exact binary scanned The built artifact, not source tree Scanning the wrong file → false confidence

SCA — the dependency gate (PR and CI)

Software Composition Analysis answers “what’s in our dependency tree and is any of it dangerous?” It runs twice: in the PR to block a developer adding a vulnerable or badly-licensed dependency, and again in CI as a defence-in-depth check on the assembled build. SCA policy distinguishes vulnerability severity (block High+ CVEs, especially known-exploited ones) from licence policy (block copyleft licences that legal hasn’t cleared). The output also feeds the SBOM you publish with the artifact.

# CI pipeline (excerpt) — Veracode SCA via the agent, after restore from the internal feed
  - script: |
      curl -sSL https://tools.veracode.com/veracode-cli/install | sh
      ./veracode sca scan --recursive . \
        --fail-on-severity high \
        --fail-on-cvss 7.0 \
        --format table
    displayName: 'Veracode SCA (block High / CVSS ≥ 7.0)'
    env:
      SRCCLR_API_TOKEN: $(VERACODE_SCA_TOKEN)   # from the KV-linked variable group, not inline

A subtle point that catches teams: SCA must run after the restore, because it analyses the resolved dependency graph (the exact versions the feed gave you), not the declared ranges in your manifest — a ^1.2.0 that resolves to a vulnerable 1.2.9 is only visible post-restore. And because restore comes from the internal feed, SCA scans what actually ships.

SCA policy dimension What it gates on Block when… Allow/mitigate when…
Vulnerability severity CVE severity of a component High / Very High, or known-exploited Low/Medium with no exploit + a tracked ticket
CVSS threshold Numeric score ≥ 7.0 (your floor) Below floor, or vendor-disputed (VEX)
Licence policy SPDX licence class Un-cleared copyleft (GPL family) Permissive (MIT/Apache/BSD)
Transitive depth Direct vs transitive Always scan transitive (Never ignore transitive — that’s where it hides)
Fix availability Is there a fixed version? Block if a safe upgrade exists Time-boxed mitigation if no fix yet

Policy Scan in CD and Container scanning at both ends

The Static (Policy) Scan is the authoritative one: it uploads the full build and evaluates it against the organisation’s policy (severity rules, scan frequency, grace periods). It is slower, so it runs in CD before Production, not per-PR — a release does not reach prod unless policy compliance is “Passed.” This is the gate auditors care about, and the one you must never relax to ship; the right move for a real un-fixable finding is a time-boxed mitigation/exception recorded in Veracode, not lowering the policy.

# CD pipeline (pre-Production stage) — Veracode Policy Scan must pass before prod
  - task: Veracode@3
    displayName: 'Veracode Policy Scan (authoritative gate)'
    inputs:
      ConnectionDetailsSelection: 'Service Connection'
      AnalysisService: 'veracode-platform'         # OIDC-backed service connection
      veracodeAppProfile: 'enterprise-web-api'
      version: '$(Build.BuildNumber)'
      filepath: '$(Pipeline.Workspace)/drop/app.zip'
      failBuildOnPolicyFail: true                   # ← fails closed on policy violation
      importResults: true
      maximumWaitTime: '60'

Container scanning closes the image-layer gap that SAST/SCA on application code miss: a perfectly clean app on a base image with a vulnerable OpenSSL is still vulnerable. It runs in the PR (so a base-image bump is reviewed) and again in CD (so the exact image being promoted is verified). Here is how the four scan types divide the attack surface — none is redundant.

Scan Covers Misses Without it you ship…
Pipeline Scan (SAST) Your source code flaws (injection, XSS) Dependency + image issues Injectable code
SCA Third-party library CVEs + licences Your own bugs, OS layer A known-vuln library
Policy Scan Whole-build vs org policy (authoritative) (Slow; not per-PR) A non-compliant build
Container scan Base image + OS package CVEs App logic, app deps A vulnerable runtime image

A discipline that keeps all of this honest: every scan reads its credentials from the KV-linked variable group, never an inline secret, and every scan fails the build on a real finding — the moment a team adds continueOnError: true to “unblock the release,” the gate is gone and the audit trail lies. For the broader supply-chain picture, see Software Supply Chain: SBOM, VEX & Admission Verification.

Secrets without secrets: Key Vault, variable groups and OIDC

The platform’s secret model has one rule: nothing reusable is ever stored in the pipeline. Secrets live in Key Vault (one per landing-zone scope — Corp Non-Prod, Corp Prod, each with its own vault), and pipelines read them at run time through a variable group linked to the vault, authenticated by a service connection that uses workload-identity federation (OIDC). No PAT, no client secret, no stored credential — Azure DevOps mints a short-lived token, Entra ID trusts the pipeline’s federated identity, and Key Vault RBAC authorises the specific secrets read.

Why OIDC, concretely

A classic Azure service connection stores a client secret (or certificate) for an app registration. That secret is a long-lived credential sitting in Azure DevOps that can leak and be replayed. Workload-identity federation removes it entirely: the app registration trusts a federated credential whose subject is the pipeline’s identity (org/project/pipeline), so Entra issues a token to that pipeline without any stored secret. Rotation becomes a non-event because there is nothing to rotate.

Auth model Stored credential Rotation burden Blast radius if leaked Verdict
PAT in a variable A long-lived token Manual, frequent Full token replay Never — the anti-pattern
Service principal + client secret A client secret Periodic, manual Replayable until expiry Legacy; avoid for new work
Service principal + certificate A certificate Cert lifecycle Cert theft Better, still a stored credential
Workload-identity federation (OIDC) None None No reusable artifact The standard

Create the service connection with workload identity, then link a variable group to the vault through it:

# Create an Azure RM service connection using workload identity federation (OIDC)
az devops service-endpoint azurerm create \
  --name 'azure-corp-nonprod-oidc' \
  --azure-rm-service-principal-id "$APP_ID" \
  --azure-rm-subscription-id "$SUB_ID" --azure-rm-subscription-name 'Corp-NonProd' \
  --azure-rm-tenant-id "$TENANT_ID" \
  --organization "$ADO_ORG" --project 'enterprise-web'
# (Federated credential on the app registration trusts this connection's subject)

# Grant the app registration's identity least-privilege RBAC on the vault
az role assignment create \
  --assignee "$APP_OBJECT_ID" \
  --role 'Key Vault Secrets User' \
  --scope $(az keyvault show -n kv-corp-nonprod --query id -o tsv)

The federated credential on the Entra side is the load-bearing piece — its subject must match the pipeline exactly. In Terraform (the platform manages app registrations and federations as IaC):

resource "azuread_application_federated_identity_credential" "ado_pipeline" {
  application_id = azuread_application.cicd.id
  display_name   = "ado-enterprise-web-ci"
  audiences      = ["api://AzureADTokenExchange"]
  issuer         = "https://vstoken.dev.azure.com/${var.ado_org_id}"
  # subject ties the token to THIS org/project/pipeline — a mismatch is the #1 failure
  subject        = "sc://${var.ado_org}/${var.ado_project}/azure-corp-nonprod-oidc"
}

Linking the variable group and consuming a secret

A variable group in the Azure DevOps Library, with “Link secrets from an Azure key vault” enabled, exposes chosen vault secrets as pipeline variables — fetched fresh each run, never persisted in the definition.

# CI pipeline — consume KV-backed secrets via a linked variable group
variables:
  - group: 'corp-nonprod-kv'   # linked to kv-corp-nonprod via the OIDC service connection

steps:
  - task: AzureKeyVault@2
    inputs:
      azureSubscription: 'azure-corp-nonprod-oidc'   # OIDC service connection
      KeyVaultName: 'kv-corp-nonprod'
      SecretsFilter: 'sql-conn-string,sca-token,datadog-api-key'
      RunAsPreJob: true
  - script: |
      # secrets are now env vars for THIS job only; never echoed
      ./deploy.sh   # reads $(sql-conn-string) etc. from the environment
    displayName: 'Use secrets (no plaintext, no echo)'

Two rules keep this safe: mark every linked secret as secret so Azure DevOps masks it in logs (a value pasted as a plain variable is not masked), and scope the variable group per environment so the Prod vault group is linked only in the Production stage, behind the approval gate, and a Non-Prod run physically cannot read a Prod secret. The deeper mechanics are covered in Azure DevOps Variable Groups, Secret Files & Key Vault Link, and the zero-plaintext pattern in Secret Management in Pipelines with Key Vault & Managed Identity.

Secret-handling rule Why What breaks if ignored
Link from Key Vault, don’t paste No secret in the pipeline definition A secret committed to YAML/Git
Use OIDC, not a stored credential Nothing reusable to leak A replayable PAT/client secret
Mark linked vars as secret Masking in logs Secret printed in a build log
Scope groups per environment Prod secrets only in Prod stage Non-Prod run reads Prod secret
Least-privilege RBAC on the vault Limit blast radius Pipeline can read every secret
Never echo $(secret) Masking can’t catch transforms Re-encoded secret leaks unmasked

The artifact: internal-feed enforcement and an immutable build

CI builds once and produces an immutable, versioned artifact that every later environment promotes unchanged. Its dependencies come only from the internal Azure Artifacts feed with public registries blocked — a typosquatted reqeusts or a hijacked npm package cannot enter the build because the build is not allowed to talk to the public registry at all. Enforcement is per-ecosystem, in the restore config:

# .npmrc committed to the repo — restore ONLY from the internal feed
registry=https://pkgs.dev.azure.com/enterprise/_packaging/corp-npm/npm/registry/
always-auth=true
# no public registry fallback configured → a package not in the feed FAILS the restore
<!-- NuGet.config — single internal source, public nuget.org NOT listed -->
<configuration>
  <packageSources>
    <clear />
    <add key="corp-nuget"
         value="https://pkgs.dev.azure.com/enterprise/_packaging/corp-nuget/nuget/v3/index.json" />
  </packageSources>
</configuration>

The feed’s upstream sources are the controlled valve: the Packages team configures which public packages may flow into the feed and at which versions, so a developer needing a new public package requests it upstream rather than a build silently reaching the internet. Versioning and upstream mechanics are covered in Azure DevOps Artifacts Feeds, Upstream Sources & Versioning and the general pattern in Artifact Registry Management.

Ecosystem Restore config Internal-feed enforcement Public-registry risk it blocks
npm .npmrc registry= set to feed; no fallback Typosquats, hijacked maintainers
NuGet NuGet.config <clear/> then single feed source Malicious package id squat
Maven settings.xml <mirror> of central → feed Dependency confusion
Python pip.conf / pip.ini index-url = feed only PyPI typosquats

The CI build then assembles, versions (the semantic tag from GitFlow, e.g. 2.4.0), runs SCA on the resolved graph, runs unit + integration tests, and publishes the single artifact plus its SBOM. From here, nothing is rebuilt — CD promotes this artifact.

CI stage step What it produces Why it’s here (not in CD)
Fetch secrets (KV/OIDC) Run-time secrets Build needs feed/signing creds
Restore (internal feed) Resolved dependency graph Provenance starts here
SCA scan Vuln/licence verdict + SBOM input Scan what actually ships
Build + version The versioned artifact One build, promoted everywhere
Unit + integration tests Internal-correctness verdict Fast feedback before publish
Publish artifact + SBOM Immutable promotion input CD consumes, never rebuilds

The testing layer as a promotion gate

CD promotes the artifact through Dev → SIT → QA → Staging → UAT → Pre-Prod → Production, and the testing layer is what makes a promotion conditional. Each framework answers a different question and runs where that question matters: WebdriverIO for API contract, Playwright for UI journeys, Azure Load Test (a managed JMeter runner driving a JMX plan) for load. A stage does not promote if its relevant test breaches criteria.

Test type Tool Question Runs at Gate criterion
Unit (framework) Is a function correct? CI Coverage + pass rate
Integration (framework) Do components work together? CI All pass
API contract WebdriverIO Does the API honour its contract? SIT / QA Suite passes, no contract drift
UI journey Playwright Can a user complete the flow? QA / Staging / UAT Critical journeys pass
Load Azure Load Test (JMX) Does it survive realistic load? Pre-Prod p95 + error-rate within budget

Azure Load Test driving a JMX plan

Azure Load Test runs your existing JMeter .jmx plan at cloud scale and — critically for a gate — supports pass/fail criteria (e.g. “p95 response time < 800 ms” and “error rate < 1%”). Wire it into the Pre-Prod stage; a breach fails the stage and the release does not proceed.

# CD pipeline — Pre-Prod load gate using Azure Load Testing + a JMX plan
  - task: AzureLoadTest@1
    displayName: 'Load test (JMX) — gate on p95 + error rate'
    inputs:
      azureSubscription: 'azure-corp-nonprod-oidc'   # OIDC service connection
      loadTestConfigFile: 'load/config.yaml'         # references the .jmx + criteria
      resourceGroup: 'rg-loadtest'
      loadTestResource: 'alt-enterprise'
# load/config.yaml — the JMX plan plus pass/fail criteria (the actual gate)
testName: 'web-api-peak'
testPlan: 'load/web-api.jmx'
engineInstances: 4
failureCriteria:
  - avg(response_time_ms) > 800
  - percentage(error) > 1
  - p95(response_time_ms) > 1200    # ← breach here fails the stage → no promotion

The load test lives at Pre-Prod, not earlier, because it needs a production-like environment to mean anything and it is expensive per run — so you run it once at the last gate before prod. The JMX plan should model real peak: concurrency, think-time, and the chatty outbound calls that cause connection-pool and SNAT problems under load.

Azure Load Test knob What it controls Typical gate value Watch-out
engineInstances Parallel load generators 2–10 (model real peak) More engines = more cost/run
failureCriteria The pass/fail rules p95, avg, error-% thresholds No criteria → it’s not a gate
Test plan (.jmx) The traffic model Real concurrency + think-time A toy plan gives false confidence
Server-side metrics App/infra metrics during run App Service + DB metrics Correlate breaches to a resource
Where it runs Stage placement Pre-Prod Earlier = too noisy/expensive

Playwright (UI) and WebdriverIO (API)

Playwright drives a real browser through the critical user journeys (login, checkout, the money paths) against a deployed environment; it gates the UI stages. WebdriverIO exercises the API contract — status codes, schemas, auth — and gates the integration stages. Both produce JUnit-format results that Azure DevOps surfaces in the Tests tab, so a failure is visible and blocking, not buried in a log.

# CD — Playwright UI gate (QA stage), publishing results so failures block + are visible
  - script: |
      npm ci
      npx playwright install --with-deps chromium
      npx playwright test --reporter=junit
    displayName: 'Playwright UI journeys'
    env:
      BASE_URL: 'https://web-qa.enterprise.internal'
  - task: PublishTestResults@2
    condition: always()
    inputs:
      testResultsFormat: 'JUnit'
      testResultsFiles: 'results.xml'
      failTaskOnFailedTests: true   # ← a failed journey fails the stage

The discipline that separates a gate from theatre: a failed test is a blocked release, not a retry-until-green — flakiness is a bug in the test, not a reason to add --retries=5 and ship anyway. The testing philosophy and quality-gate design are expanded in Shift-Left Testing & Quality Gates.

Discipline Right way The anti-pattern it prevents
Failures block failTaskOnFailedTests: true “Tests ran” but didn’t gate
Flakiness is a bug Fix the test/selector Blanket --retries masking real breaks
Results are visible Publish JUnit → Tests tab Failure buried in stdout
Right test, right stage UI at QA, load at Pre-Prod Running everything everywhere
Test the deployed env Point at the stage URL Testing localhost, shipping prod

Blue-green by slot swap and the gated production release

Production deploys are blue-green via App Service deployment slots: CD deploys to the staging slot, warms it, runs final checks, then swaps staging into production — an atomic, near-zero-downtime cutover with an instant swap-back if anything is wrong. The production swap is gated: an Azure DevOps environment with required approvals (leads + production managers) holds the release until a human authorises it.

# CD — Production stage: deploy to staging slot, warm, then gated swap
  - stage: Production
    dependsOn: PreProd
    jobs:
      - deployment: deploy_prod
        environment: 'production'      # ← approvals (leads + prod managers) configured on this environment
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'azure-corp-prod-oidc'
                    appName: 'web-enterprise-prod'
                    deployToSlotOrASE: true
                    slotName: 'staging'         # deploy to staging slot first
                    package: '$(Pipeline.Workspace)/drop/app.zip'
                - task: AzureAppServiceManage@0
                  inputs:
                    azureSubscription: 'azure-corp-prod-oidc'
                    action: 'Swap Slots'
                    webAppName: 'web-enterprise-prod'
                    sourceSlot: 'staging'        # swap staging → production (blue-green cutover)

The approval lives on the environment, not in YAML, so it cannot be edited away in a PR — a clean separation of “who can change the pipeline” from “who can authorise a release.” Configure it as a check:

# The 'production' environment carries an approval check (leads + prod managers)
# configured in the Azure DevOps Environments UI; the stage targeting it WAITS for sign-off.
az pipelines run --name 'web-enterprise-cd' --branch main   # halts at Production until approved
Production-release control Mechanism Why it’s placed here
Deploy to staging slot first deployToSlotOrASE + slotName Never deploy straight to live
Warm-up before swap Slot warm-up ping path Production never serves cold workers
Atomic swap Swap Slots action Near-zero-downtime cutover
Instant rollback Swap back The old version is one swap away
Human approval Environment check (leads + mgrs) Authorisation, not in editable YAML
Container/Policy scan pre-swap Veracode in the stage No unscanned image reaches prod

The slot-swap blue-green pattern, warm-up and rollback mechanics are detailed in Deployment Strategies: Blue-Green, Canary & Rolling and the App Service specifics in Hardening App Service: VNet Integration, Private Endpoints & Zero-Downtime Slots and App Service Deployment Slots: Swap Warm-Up Setup.

Datadog observability and release tracking

The most valuable observability signal is “a change happened, here, at this version” — and the pipeline is the only thing that knows it at the exact instant. So CD emits a deployment event to Datadog the moment the slot swaps, tags APM traces and metrics with the version, and lets SLO/burn-rate monitors do the rest; when something regresses, the deployment overlay pins it to a release without a hunt.

Posting a deployment marker from CD

The cleanest way is the Datadog Events API (or the datadogci deployment mark CLI for DORA-style change tracking), called as the last step of a successful production swap, with the Datadog API key pulled from Key Vault.

# CD — post a deployment marker to Datadog immediately after the production swap
  - script: |
      curl -sS -X POST "https://api.datadoghq.com/api/v1/events" \
        -H "DD-API-KEY: $(datadog-api-key)" \
        -H "Content-Type: application/json" \
        -d '{
          "title": "Deploy web-enterprise '"$(Build.BuildNumber)"' to Production",
          "text": "Slot swap complete. version:'"$(Build.BuildNumber)"' service:web-enterprise",
          "tags": ["service:web-enterprise","env:prod","version:'"$(Build.BuildNumber)"'","source:azure-devops"],
          "alert_type": "info"
        }'
    displayName: 'Datadog deployment marker'

Tagging traces with the version (via DD_VERSION in the App Service settings, set by the deploy) is what makes “this release’s error rate” a question Datadog can answer. With Unified Service Tagging (service, env, version) consistent across the marker, the APM traces and the monitors, a single dashboard correlates a regression to a release.

Datadog signal Where it comes from What it answers Tag that ties it together
Deployment event CD posts it at swap “When did this version go live?” version, env, service
APM trace App, instrumented “What’s slow / failing, in which version?” version
SLO + burn-rate monitor Datadog config “Are we burning the error budget?” service, env
Change overlay Deployment events on a graph “Did this start at a release?” version
Monitor → rollback signal Burn-rate breach post-deploy “Should we swap back?” version, env

SLOs and the rollback decision

Define an SLO (e.g. 99.9% of requests under 800 ms over 30 days) and a multi-window burn-rate monitor on it; after a deploy marker, a fast-burn alert that starts right at the overlay is your rollback trigger — swap the slot back (web/function) or run a Liquibase rollback (database) to the previous tagged changeset. The idea that monitors inform automated or human rollback connects to Observability: Logs, Metrics, Traces & SLOs and the DORA framing in DORA Metrics & Platform Engineering.

Burn-rate window Detects Typical threshold Action
Fast (e.g. 5m/1h) Acute regression post-deploy 14.4× budget burn Page on-call; consider swap-back now
Slow (e.g. 1h/6h) Gradual degradation 6× budget burn Investigate; likely roll back at leisure
SLO status Overall budget health < target remaining Freeze risky releases
Deploy-correlated Breach aligned to a marker Overlay alignment Roll back that version

Mobile delivery: Android and iOS pipelines

Mobile is where pipelines that work everywhere else fall over, because two constraints have no web equivalent: code signing (iOS especially is unforgiving — wrong cert or provisioning profile and the build or the store rejects it) and store distribution (a human-reviewed, multi-track release process). The platform runs separate Android and iOS pipelines that still obey the same rules — SCA, internal-feed enforcement, versioning — then add the mobile-specific steps: build the APK/IPA, install the iOS signing certificate, and distribute via Applivery → Alpha/TestFlight → Play Store/App Store.

The two platforms differ at almost every step; knowing the row removes the guesswork.

Step Android iOS
Agent Linux scale-set agent macOS agent (Xcode required)
Build output .apk / .aab .ipa
Signing material Keystore (.jks) + passwords Certificate (.p12) + provisioning profile
Signing step apksigner / Gradle signingConfig Install cert to keychain + profile
Versioning versionCode + versionName CFBundleVersion + CFBundleShortVersionString
Beta track Applivery → Play Internal/Alpha Applivery → TestFlight
Store Play Store App Store (App Store Connect)

The Android pipeline

Android builds on a Linux agent. The keystore and its passwords come from Key Vault (secret file + secret variables), never committed; SCA runs on the Gradle graph, the feed is enforced via Gradle/Maven settings, the version is stamped, the signed .aab is built, and Applivery distributes to the Alpha track.

# Mobile (Android) pipeline — SCA, feed-enforced build, sign, distribute via Applivery
pool: { name: 'selfhosted-vmss-linux' }
variables:
  - group: 'mobile-corp-kv'   # keystore password, Applivery token — KV-linked
steps:
  - task: DownloadSecureFile@1
    name: keystore
    inputs: { secureFile: 'release.jks' }          # keystore stored as a secure file, not in Git
  - script: ./veracode sca scan --fail-on-severity high .
    displayName: 'Veracode SCA (Gradle graph)'
  - script: |
      ./gradlew bundleRelease \
        -PversionCode=$(Build.BuildId) -PversionName=2.4.0 \
        -Pandroid.injected.signing.store.file=$(keystore.secureFilePath) \
        -Pandroid.injected.signing.store.password=$(keystore-password)
    displayName: 'Build + sign signed AAB (internal-feed restore)'
  - script: |
      curl -sS -X POST "https://api.applivery.io/v1/apps/$(APPLIVERY_APP)/builds" \
        -H "Authorization: bearer $(applivery-token)" \
        -F "package=@app/build/outputs/bundle/release/app-release.aab" \
        -F "versionName=2.4.0" -F "tags=alpha"
    displayName: 'Distribute to Applivery (Alpha)'

The iOS pipeline — the certificate dance

iOS must run on a macOS agent and the signing setup is what breaks. The platform installs the Apple distribution certificate (.p12, from Key Vault as a secure file) into a temporary keychain, installs the provisioning profile, then builds and exports the .ipa with the matching code-sign identity. A mismatch between certificate, profile, bundle id and export method is the single most common failure.

# Mobile (iOS) pipeline — install cert + profile, build IPA, distribute via Applivery → TestFlight
pool: { name: 'selfhosted-vmss-macos' }   # macOS agent with Xcode
variables:
  - group: 'mobile-ios-corp-kv'
steps:
  - task: InstallAppleCertificate@2
    inputs:
      certSecureFile: 'distribution.p12'        # from Key Vault / secure files
      certPwd: '$(p12-password)'
      keychain: 'temp'                          # ephemeral keychain on the agent
  - task: InstallAppleProvisioningProfile@1
    inputs:
      provisioningProfileLocation: 'secureFiles'
      provProfileSecureFile: 'enterprise_appstore.mobileprovision'
  - script: ./veracode sca scan --fail-on-severity high .
    displayName: 'Veracode SCA'
  - task: Xcode@5
    inputs:
      actions: 'archive'
      scheme: 'EnterpriseApp'
      packageApp: true
      exportOptions: 'plist'
      exportOptionsPlist: 'ios/ExportOptions.plist'   # method must match the profile (app-store)
      signingOption: 'manual'
      signingIdentity: 'Apple Distribution'
      provisioningProfileName: 'Enterprise AppStore'
    displayName: 'Build + sign IPA'
  - script: |
      curl -sS -X POST "https://api.applivery.io/v1/apps/$(APPLIVERY_IOS_APP)/builds" \
        -H "Authorization: bearer $(applivery-token)" \
        -F "package=@output/EnterpriseApp.ipa" -F "tags=testflight"
    displayName: 'Distribute to Applivery (→ TestFlight)'

The distribution ladder exists because each rung widens the audience and adds review — Applivery gives internal testers an over-the-air install with no review, TestFlight/Play tracks add Apple/Google beta review, and the public store is the final fully-reviewed release. Treating these as one undifferentiated “ship it” is how an untested build reaches real users.

Distribution rung Audience Review Use for
Applivery Internal testers / QA None (private) Fast OTA installs of every build
TestFlight (iOS) External beta testers Apple beta review Pre-release beta on iOS
Play Internal / Alpha External beta testers Google review Pre-release beta on Android
App Store Public Full Apple review Production iOS release
Play Store Public Google review Production Android release
Mobile failure mode Tell-tale Fix
iOS: wrong/expired cert “No signing certificate found” Install the current distribution .p12 to the keychain
iOS: profile/bundle mismatch “Provisioning profile doesn’t match” Align bundle id + profile + export method
iOS: export method wrong Archive ok, export fails ExportOptions.plist method = app-store
Android: unsigned/debug-signed Store rejects upload Use the release keystore signingConfig
Either: version not bumped Store: “version already exists” Stamp versionCode/CFBundleVersion per build
Either: secret in repo Keystore/cert committed Move to Key Vault / secure files
Either: public dep pulled SCA flags an unexpected package Enforce the internal feed in Gradle/CocoaPods

Database delivery: versioning with Liquibase

Schema is the one delivery surface where “roll forward” is not enough — a bad migration must roll back without losing data, and ad-hoc DDL on production is forbidden. Liquibase makes schema a versioned, reviewable, reversible artifact: an ordered set of changesets in a changelog, each with a forward change and (ideally) a rollback, tracked in the database via DATABASECHANGELOG. The pipeline gets its connection secrets from Key Vault, validates the changelog, runs update to apply pending changesets, and can rollback to a tag — against Azure Database for MySQL, Azure Database for PostgreSQL, and Azure SQL with the same model.

The changelog and a reversible changeset

A changeset declares its change and its rollback. Liquibase auto-generates rollbacks for many operations, but for anything lossy (dropping a column) you write the rollback explicitly — that discipline is what makes 03:00 survivable.

<!-- db/changelog/changelog-master.xml — ordered, each change reversible -->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
  <changeSet id="2024.05-add-orders-status" author="vinod">
    <addColumn tableName="orders">
      <column name="status" type="varchar(20)" defaultValue="pending"/>
    </addColumn>
    <rollback>
      <dropColumn tableName="orders" columnName="status"/>   <!-- explicit reverse -->
    </rollback>
  </changeSet>

  <changeSet id="2024.05-tag-release-2.4.0" author="vinod">
    <tagDatabase tag="2.4.0"/>   <!-- a named point you can roll back TO -->
  </changeSet>
</databaseChangeLog>

The database pipeline

# Database pipeline — secrets from KV, validate, update; rollback is a separate, gated job
variables:
  - group: 'corp-prod-kv'    # KV-linked: db-url, db-user, db-password
steps:
  - script: |
      liquibase --url="$(db-url)" --username="$(db-user)" --password="$(db-password)" \
                --changeLogFile=db/changelog/changelog-master.xml \
                validate                                   # checksum + structure check (fails closed)
    displayName: 'Liquibase validate'
  - script: |
      liquibase --url="$(db-url)" --username="$(db-user)" --password="$(db-password)" \
                --changeLogFile=db/changelog/changelog-master.xml \
                updateSQL > update-preview.sql            # human-reviewable preview of the DDL
      liquibase --url="$(db-url)" --username="$(db-user)" --password="$(db-password)" \
                --changeLogFile=db/changelog/changelog-master.xml \
                update                                     # apply pending changesets
    displayName: 'Liquibase update (apply pending)'
# Rollback job (run only on a release failure, behind approval)
  - script: |
      liquibase --url="$(db-url)" --username="$(db-user)" --password="$(db-password)" \
                --changeLogFile=db/changelog/changelog-master.xml \
                rollback 2.4.0           # roll the schema back to the tagged point
    displayName: 'Liquibase rollback to tag 2.4.0'

The three engines differ in connection string, driver and a few DDL behaviours; Liquibase abstracts most of it but the JDBC URL and lock behaviour vary.

Target JDBC URL shape Driver Azure auth note
Azure Database for MySQL jdbc:mysql://srv.mysql.database.azure.com:3306/db?useSSL=true MySQL Connector/J Entra or password from KV; SSL required
Azure Database for PostgreSQL jdbc:postgresql://srv.postgres.database.azure.com:5432/db?sslmode=require PostgreSQL JDBC Entra or password from KV; sslmode=require
Azure SQL jdbc:sqlserver://srv.database.windows.net:1433;database=db;encrypt=true mssql-jdbc Entra token or KV password; encrypt on
Liquibase command What it does When to run Gate behaviour
validate Checksum + structural sanity Every run, first Fails closed on drift/error
status Lists pending changesets Before update (visibility) Informational
updateSQL Previews the DDL (no apply) Before update (review) Human review of generated SQL
update Applies pending changesets The deploy Stops on a failing changeset
tag Names a rollback point Per release Enables rollback <tag>
rollback <tag> Reverts to a named point On release failure Gated, separate job
Liquibase discipline Why What breaks without it
Every lossy change has an explicit <rollback> Auto-rollback can’t reverse a drop No down-path at 03:00
Tag each release A named point to roll back to Rollback target is ambiguous
Never edit an applied changeset Checksums detect tampering validate fails / drift
Secrets from Key Vault No DB password in Git/logs Leaked production credentials
Preview with updateSQL Review the actual DDL A surprising migration in prod
One changelog, all envs Same schema everywhere Env-specific drift

The Terraform modules that provision these databases — and the secret-rotation that feeds Liquibase — live in Terraform Module: Azure MySQL Flexible Server, Terraform Module: Azure PostgreSQL Flexible Server and Terraform Module: Azure SQL Database, with the Key Vault module in Terraform Module: Azure Key Vault.

Architecture at a glance

Follow the artifact left to right. A feature/* branch opens a pull request into development; the PR pipeline fires on the centralised scale-set fleet and runs the shift-left gates — lint, code analysis, unit tests with coverage, and Veracode Pipeline Scan (SAST), SCA and a Container scan. A finding above your severity floor blocks the merge (badge 1). Merge clean, and the CI pipeline takes over: it fetches secrets from Key Vault through a variable group linked over an OIDC service connection (badge 2 — no stored credential), restores only from the internal Azure Artifacts feed with public registries blocked (badge 3), builds and versions one immutable artifact, runs SCA again, executes unit + integration tests, and publishes the artifact plus its SBOM.

CD then promotes that single artifact through Dev → SIT → QA → Staging → UAT → Pre-Prod → Production. The testing layer is the gate (badge 4): WebdriverIO checks the API contract, Playwright drives the UI journeys, and Azure Load Test runs the JMX plan with p95/error-rate criteria at Pre-Prod — a breach stops the line. The Production stage deploys to the staging slot, warms it, runs the authoritative Veracode Policy Scan and Container scan, then waits on a gated approval (leads + production managers) before the slot swap cuts blue-green to live. The same spine feeds three surfaces: web + function apps on App Service, the mobile pipelines that sign and ship APK/IPA via Applivery → Alpha/TestFlight → the stores (badge 5), and Liquibase changelogs that version Azure MySQL/PostgreSQL/MS SQL with a real rollback path. The instant the swap completes, CD posts a deployment event to Datadog and tags traces with the version (badge 6) — so when an SLO burn-rate monitor fires twelve minutes later, the overlay pins it to exactly this release.

Left-to-right enterprise Azure DevOps delivery architecture: a PR-gate zone (PR pipeline, Veracode SCA/Pipeline/Container scans, quality gate) feeding a CI-build zone (Key Vault secrets via variable group and OIDC, CI pipeline, internal Azure Artifacts feed), feeding a test-and-CD-promotion zone (seven CD stages with approvals, the test layer of Azure Load Test JMX, Playwright and WebdriverIO, and App Service slot swap), feeding a delivery-surfaces zone (web and function apps, mobile APK/IPA to Applivery, Liquibase against MySQL/PostgreSQL/MS SQL), with a final observe zone where Datadog receives deployment markers and SLO signals. Numbered badges mark the Veracode gate, OIDC secret fetch, internal-feed enforcement, the test gate, mobile signing/store distribution, and release marking.

Real-world scenario

Northwind Retail Group (a fictional but representative enterprise) ran a Black-Friday-scale flash sale on a platform exactly like this one: an Azure DevOps org, scale-set agents, the three-pipeline model, Veracode gates, Datadog, plus an iOS/Android app and an Azure MySQL backing store managed by Liquibase. Eleven days before the sale, three near-misses landed in one week — and each was caught by a gate, which is the point.

First, a developer bumped a logging library and the PR Pipeline Scan stayed green but SCA flagged a transitive Very High CVE pulled in by the new version, with a fixed release available. The merge was blocked; the developer pinned the fixed transitive version (sourced through the internal feed’s upstream allow-list), SCA went green, and the bad dependency never reached the trunk. Total cost: twenty minutes. Had SCA only run in CI (post-merge), the vulnerable tree would have been in development and on top of it by the time anyone noticed.

Second, the Pre-Prod Azure Load Test failed its criterion: at modelled peak the p95 climbed to 1,640 ms against an 800 ms budget, and the error rate touched 3%. The JMX plan reproduced the flash-sale concurrency that no unit test ever would. Server-side metrics in the load-test run showed the App Service plan CPU-pinned and the MySQL connection count flat-lined at its ceiling — a classic connection-pool exhaustion under load. The team raised the pool size, added a read replica for the catalogue queries (a Liquibase-free config change), and the re-run passed at p95 720 ms. The release did not promote until it did. Without the load gate, the first real customers would have been the load test.

Third — and this is the one that proves observability earns its place — the successful release two days before the sale introduced a subtle regression: a new feature flag path that, under a specific locale, threw on checkout for about 0.4% of sessions. Unit and Playwright tests didn’t cover that locale. But CD had posted a Datadog deployment marker at swap, and a fast-burn SLO monitor on the checkout success-rate fired nineteen minutes later. The dashboard overlay pinned it precisely to release 2.4.0. The on-call did not investigate code; they swapped the slot back (a forty-second operation), checkout recovered, and the fix shipped the next morning behind the same gates. The mobile app had gone out through Applivery → TestFlight a week earlier, caught a signing-profile mismatch (wrong export method in ExportOptions.plist) that the iOS pipeline failed on, got corrected, and reached the App Store cleanly. Northwind’s leads later described the week not as “three incidents” but as “three gates doing their job” — gates as the product, which is the entire thesis of this article.

Advantages and disadvantages

The honest trade-offs of running this much gating and surface coverage:

Advantages Disadvantages
Bad code/deps/images blocked before merge (shift-left) More pipeline complexity and minutes (cost)
No stored secrets — OIDC removes the leak class OIDC setup (federated subjects) is fiddly to get right
Internal-feed enforcement kills supply-chain confusion A new public dep needs an upstream request (friction)
Tests are real gates, not theatre Flaky tests block releases until fixed (forces discipline)
Blue-green swap = near-zero-downtime + instant rollback Slots add plan cost and warm-up complexity
Datadog markers pin regressions to a release in seconds Telemetry + Datadog cost; tagging hygiene required
Mobile + DB are first-class, gated surfaces macOS agents + signing + Liquibase rollback discipline
Auditable: every gate has a pass/fail record “Did Not Pass” can block a release under deadline pressure

When each matters: the security and feed gates matter most for regulated or high-trust workloads (finance, health) where a single bad dependency is an incident; the load gate matters most for spiky-traffic workloads (retail, ticketing); the Datadog/rollback loop matters most where MTTR is the business metric; and the mobile/DB discipline matters most where those surfaces are revenue paths, not afterthoughts. The cost is real but asymmetric — a few extra pipeline minutes and some setup against the price of one prevented production incident.

Hands-on lab

You can prove the core ideas — a blocking scan gate, a KV-linked secret over OIDC, an internal-feed restore, and a Liquibase update/rollback — on free or near-free tiers. This lab uses an Azure DevOps free org, a free-tier App Service, and Liquibase Community against a free-tier Azure Database for PostgreSQL.

1. Create the org, project and a service connection with OIDC.

az extension add --name azure-devops
az devops configure --defaults organization=https://dev.azure.com/<you>
az devops project create --name 'lab-delivery'
# In Project Settings → Service connections, create an Azure RM connection with
# "Workload Identity federation (automatic)" — this provisions the app reg + federated credential.

2. Stand up a Key Vault and grant the connection’s identity read access.

az group create -n rg-lab -l eastus
az keyvault create -n kv-lab-<unique> -g rg-lab --enable-rbac-authorization true
az keyvault secret set --vault-name kv-lab-<unique> --name 'db-password' --value 'P@ssw0rd-lab!'
# Grant the service connection's app registration "Key Vault Secrets User" on this vault
SP_OBJ=$(az ad sp list --display-name '<your-connection-sp>' --query '[0].id' -o tsv)
az role assignment create --assignee "$SP_OBJ" --role 'Key Vault Secrets User' \
  --scope $(az keyvault show -n kv-lab-<unique> --query id -o tsv)

3. Link a variable group to the vault (Library → + Variable group → “Link secrets from an Azure key vault”, pick the OIDC connection and kv-lab-<unique>, add db-password).

4. Add a blocking scan gate to a PR pipeline. A minimal azure-pipelines.yml that fails on a planted finding (use any SAST you have, or the Veracode Pipeline Scan if you have a trial; the principle is the non-zero exit):

trigger: none
pr: { branches: { include: [ main ] } }
pool: { vmImage: 'ubuntu-latest' }   # free hosted agent for the lab
steps:
  - script: |
      # stand-in for a scan: fail the build if a forbidden pattern is present
      if grep -rn "TODO-INSECURE" src/ ; then echo "Gate failed"; exit 1; fi
    displayName: 'Blocking scan gate (fail closed)'

Open a PR that adds TODO-INSECURE and watch the merge get blocked; remove it and watch it pass. That non-zero exit is the gate.

5. Liquibase update and rollback against free-tier PostgreSQL.

az postgres flexible-server create -n pg-lab-<unique> -g rg-lab \
  --tier Burstable --sku-name Standard_B1ms --public-access 0.0.0.0 \
  --admin-user labadmin --admin-password 'P@ssw0rd-lab!'
# changelog with one reversible changeset + a tag (as shown earlier), then:
liquibase --url="jdbc:postgresql://pg-lab-<unique>.postgres.database.azure.com:5432/postgres?sslmode=require" \
  --username=labadmin --password='P@ssw0rd-lab!' \
  --changeLogFile=changelog.xml update
liquibase ... rollback 1.0   # confirm the column is gone again

6. Teardown — delete the resource group and the project to stop all charges:

az group delete -n rg-lab --yes --no-wait
az devops project delete --id $(az devops project show --project lab-delivery --query id -o tsv) --yes

Expected outcomes: the PR is genuinely blocked by the failing step; the pipeline reads db-password from the vault with no secret in the YAML; and the Liquibase rollback cleanly reverses the update. Those three behaviours are the load-bearing ideas of the whole article, proven for under the cost of a coffee.

Common mistakes & troubleshooting

The failure modes that cost the most time, with the exact signal and fix. This is the section to keep open during an incident.

# Symptom Root cause Confirm with Fix
1 Veracode step “passes” but flaws still ship continueOnError/` true` on the scan
2 SCA misses a vulnerable dependency Scanned manifest, not resolved graph SCA ran before restore Run SCA after restore; scan transitive
3 KV task: AADSTS700213 / 403 Federated subject or RBAC mismatch The error in the AzureKeyVault task log Align federated subject; grant Key Vault Secrets User
4 Secret printed in a build log Plain variable (not marked secret), or echo Search the log for the value Mark as secret; never echo; link from KV
5 Restore pulls a public package Public registry not removed/blocked Restore log shows a public URL <clear/>/single registry=; feed-only
6 Load test “passes” but prod falls over No failureCriteria set The config has no thresholds Add p95/error-% criteria; model real peak
7 Tests “ran” but didn’t block failTaskOnFailedTests not set / results not published Tests tab empty; stage green on failures Publish JUnit; failTaskOnFailedTests: true
8 Prod deploy caused downtime Deployed straight to production, not staging slot No slot in the deploy task Deploy to staging slot → warm → swap
9 Regression not tied to a release No Datadog deploy marker / no version tag No overlay on the dashboard Post the deploy event; set DD_VERSION
10 iOS build: “No signing certificate” Cert not installed on the agent keychain Xcode log; keychain empty InstallAppleCertificate@2 with the .p12
11 iOS export fails after archive ExportOptions.plist method ≠ profile Export step error Set method app-store; match the profile
12 Store: “version already exists” Version not bumped per build Store console; build number static Stamp versionCode/CFBundleVersion
13 Liquibase validate fails An applied changeset was edited (checksum drift) validate reports a checksum error Never edit applied changesets; add a new one
14 Rollback impossible Lossy change had no <rollback> The changeset drops a column, no reverse Always write explicit rollback for drops
15 Pipeline stuck “no agent” Scale-set fleet at capacity / wrong pool name Pipeline queue shows waiting Scale the VMSS; fix the pool name

Two deserve a sentence more. #3 (the OIDC 403) is the most common new-platform failure: the federated credential’s subject must exactly match the service-connection subject (sc://org/project/connection) and the app registration must have an RBAC role on the vault — both, not either. #6 (the toothless load test) is insidious because the stage goes green; a load test without failureCriteria is a load report, not a gate. The App Service slot/swap and SNAT failure classes behind #8 and #6 are dissected hop-by-hop in Troubleshooting Azure App Service: 502/503, Cold Starts & Restart Loops.

Best practices

Security notes

Control Mechanism Secures against Also prevents
OIDC service connections Federated identity to Entra Leaked/replayed credentials Rotation breakage
KV-linked variable groups Secrets fetched at run time Secrets in pipeline definitions Plaintext in Git
Internal-feed enforcement Single feed + upstream allow-list Supply-chain/typosquat attacks Dependency confusion
Veracode gates SCA/SAST/Policy/Container Vulnerable code/deps/images Shipping a known-exploited CVE
Signing material in KV Secure files + ephemeral keychain Cert/keystore theft Malware signed as you
Least-privilege DB identity Scoped DDL rights + TLS Over-broad migration access Accidental destructive change
Hardened scale-set agents Patched images, scoped egress Compromised shared runner Prod creds in untrusted PR runs

Cost & sizing

The bill here is mostly pipeline minutes, agents, tooling licences and telemetry — and almost all of it is cheap relative to one prevented incident.

Cost driver What you pay for Rough INR / month What it buys Watch-out
Scale-set agents (VMSS) vCPU-hours at peak concurrency ₹15,000–40,000 Gates don’t queue Over-provisioning idle instances
macOS agent (iOS) Mac minutes / a self-hosted Mac ₹8,000–20,000 iOS signing + build Only needed for mobile-iOS
Azure Load Test Virtual-user / engine hours ₹2,000–8,000 (few runs) The load gate Don’t run it per-PR
Datadog Hosts + ingested GB + monitors ₹20,000–80,000+ Release tracking + SLOs Sample high-volume telemetry
Veracode Per-app/subscription licence (licence-dependent) Security gates Licence, not per-scan
Azure Artifacts Storage beyond free tier ₹0–3,000 The internal feed Prune old package versions
Applivery Per-seat / per-app SaaS (plan-dependent) Mobile distribution Right-size seats

A pragmatic mid-size posture — a shared scale-set fleet, a small macOS pool, Datadog with sampling, Veracode licensed, Azure Load Test at Pre-Prod — lands in the low lakhs per month for an org running dozens of services, and the single largest line is almost always Datadog ingestion, not the pipelines. The cheapest optimisation is sampling telemetry and right-sizing the agent fleet; the most expensive mistake is a missing gate.

Interview & exam questions

1. Where do you place a Veracode Pipeline Scan versus a Policy Scan, and why? The Pipeline Scan (fast SAST) runs in the PR pipeline as a merge gate, failing on new findings above a severity floor so bad code never reaches the trunk. The Policy Scan (full, authoritative, evaluated against the org policy) is slower and runs in CD before Production, so a release can’t go live unless policy compliance passes. Fast-and-early vs authoritative-and-late.

2. What makes a scan or test an actual gate rather than decoration? It fails closed — a real finding or failed assertion produces a non-zero exit that stops the pipeline. The anti-patterns are continueOnError/||true on a scan and --retries masking flaky tests; both make the step run without blocking, which is theatre.

3. How does the platform fetch secrets without storing any credential? Secrets live in Key Vault; pipelines read them through a variable group linked to the vault, authenticated by a service connection using workload-identity federation (OIDC). Azure DevOps mints a short-lived federated token, Entra trusts the pipeline’s identity, and Key Vault RBAC authorises the read — there is no PAT or client secret to leak.

4. A KV task fails with AADSTS700213 / 403. What two things do you check? The federated credential subject on the app registration must exactly match the service-connection subject (sc://org/project/connection), and the app registration’s identity must have an RBAC role (Key Vault Secrets User) on the vault. Both are required; a mismatch in either yields the 403.

5. Why must SCA run after the restore? SCA analyses the resolved dependency graph — the exact transitive versions the feed served — not just the declared ranges in the manifest. A ^1.2.0 that resolves to a vulnerable 1.2.9 is only visible post-restore, and transitive dependencies are where most CVEs hide.

6. What is internal-feed enforcement and what attack does it stop? Restores are configured to pull only from the internal Azure Artifacts feed, with public registries removed/blocked and upstreams allow-listed. It stops dependency confusion / typosquatting — a malicious package on a public registry cannot enter the build because the build never contacts the public registry.

7. Why does the Azure Load Test live at Pre-Prod and need failure criteria? It needs a production-like environment to be meaningful and it’s expensive per run, so it runs once at the last gate before prod, not per-commit. Without failureCriteria (p95, error-rate thresholds) it’s a load report, not a gate — the criteria are what make a breach block the promotion.

8. How does blue-green work here and how do you roll back? CD deploys to the staging slot, warms it, runs the authoritative scan, waits on a gated approval, then swaps staging into production — an atomic, near-zero-downtime cutover. Rollback is an immediate swap-back to the previous slot, a seconds-long operation, not a redeploy.

9. How do you tie a production regression to a specific release? CD posts a Datadog deployment event the instant the slot swaps and sets DD_VERSION so traces/metrics carry the version. A multi-window burn-rate SLO monitor that fires after the marker shows, via the deployment overlay, that the regression started at exactly that release — making the rollback decision obvious.

10. What’s different and dangerous about the iOS pipeline? It must run on a macOS agent, and code signing is unforgiving: the distribution certificate (.p12) is installed to an ephemeral keychain, the provisioning profile installed, and the .ipa exported with a method (app-store) that must match the profile and bundle id. A mismatch between cert, profile, bundle id and export method is the most common failure.

11. Why is the distribution ladder (Applivery → TestFlight/Play Internal → store) better than one “ship it” step? Each rung widens the audience and adds review: Applivery gives internal testers OTA installs with no review; TestFlight/Play tracks add Apple/Google beta review for external testers; the public store is the final, fully-reviewed release. Collapsing them risks an untested build reaching real users.

12. How does Liquibase make a schema change reversible, and why does it matter? Each changeset declares a forward change and (for lossy operations, explicitly) a <rollback>, tracked in DATABASECHANGELOG; you tag each release and can rollback <tag> to a named point. It matters because a forward-only migration leaves no safe down-path — a bad release becomes manual surgery on production tables instead of a clean, tested revert.

These map to AZ-400 (DevOps Engineer Expert) — designing build/release pipelines, security and compliance (Veracode-style gates), secrets management, test integration, and release strategies — with the identity/secrets angle touching AZ-500 and the App Service/slot mechanics touching AZ-204.

Question theme Primary cert Objective area
Scan placement (PR/CI/CD), gates AZ-400 Implement security & compliance in pipelines
OIDC service connections, KV secrets AZ-400 / AZ-500 Manage secrets; secure pipelines
Internal-feed enforcement, artifacts AZ-400 Manage package/feed strategy
Test integration as gates AZ-400 Implement continuous testing
Blue-green slot swap, approvals AZ-400 / AZ-204 Release strategy; App Service deploy
Datadog markers, SLOs, rollback AZ-400 Implement monitoring & feedback

Quick check

  1. A Veracode scan step shows green, yet a vulnerable dependency reached production. What’s the single most likely cause in the pipeline YAML, and how do you confirm it?
  2. Your CI’s AzureKeyVault task fails with a 403 / AADSTS700213. Name the two things that must both be correct for OIDC secret access.
  3. True or false: running the Azure Load Test on every PR is the right way to catch performance regressions early.
  4. Production was briefly down during a deploy that “used slots.” What was almost certainly missed, and what’s the correct sequence?
  5. A schema migration needs to be rolled back but there’s no down-path. What Liquibase discipline would have prevented this, and what command would the rollback have used?

Answers

  1. A gate step with continueOnError: true (or a shell || true / failBuildOnPolicyFail: false) so the scan ran but never blocked. Confirm by reading the Veracode task config in the YAML — a real gate has the fail-on-finding behaviour enabled and no error suppression.
  2. (a) The federated credential subject on the app registration must exactly match the service-connection subject (sc://org/project/connection), and (b) the app registration’s identity must hold an RBAC role (Key Vault Secrets User) on the vault. Both, not either.
  3. False. The load test needs a production-like environment and is expensive per run, so it belongs at Pre-Prod with explicit failureCriteria. Per-PR it would be noisy, slow and costly, and usually run against an environment too small to be meaningful.
  4. The deploy went straight to production (or the staging slot wasn’t warmed before swap). The correct sequence is deploy to the staging slot → warm-up ping → run the authoritative scan → gated approval → swap staging into production, with the old slot kept as an instant swap-back.
  5. Every lossy change (e.g. a dropColumn) must carry an explicit <rollback>, and each release should be tagged (tagDatabase). The rollback would then be liquibase rollback <tag> (e.g. rollback 2.4.0) to revert the schema to that named point cleanly.

Glossary

Next steps

You can now place every security, test and observability gate on an Azure DevOps platform and ship the mobile and database surfaces that usually get neglected. Build outward:

Azure DevOpsVeracodeKey VaultDatadogPlaywrightLiquibaseMobile CI/CDShift-Left Security
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