DevOps Platform Engineering

Enterprise Azure DevOps at Scale: Multi-Project Structure (IaC, Templates, Packages, Apps) + a Centralized VMSS Agent Fleet

You inherit a delivery platform that started as one Azure DevOps project with one repo, one pipeline and the hosted agents Microsoft gives you for free. Two years later it is forty repositories, ninety pipelines, six teams, and the free agent minutes ran out in week three. Every team copy-pasted the same hundred lines of YAML, every team wrote its own Terraform a little differently, every team published NuGet packages to a feed only that team can see, and the “build server” is a single beefy VM somebody pinned to a desk under their monitor. Nothing is wrong exactly — it all ships — but the blast radius of any change is the whole org, nobody can answer “where does this artifact come from,” and the security team has just discovered that a build agent has owner on production. This is the moment a delivery org needs platform engineering: not more pipelines, but a paved road — an opinionated, reusable, governed structure that every team drives on without rebuilding the road each time.

This article is the architecture of that paved road, grounded in a real enterprise shape. One Azure DevOps organisation split into projects each with exactly one job: an IaC project holding Terraform modules with one Git repo per module; a pipeline-templates project holding centralized reusable YAML that every other pipeline extends; a packages project hosting Azure Artifacts feeds (Maven, NuGet, npm, Python) with internal-feed enforcement; and many application projects (web apps, function apps, mobile) that consume the modules, the templates and the feeds. Every pipeline in every project runs on one shared self-hosted agent fleet — Azure DevOps scale-set agents backed by a VM Scale Set in the hub of a hub-spoke network — elastic, ephemeral, autoscaled, and able to reach private endpoints that Microsoft-hosted agents never could. The deploy target underneath is a Cloud Adoption Framework (CAF) landing zone: a management-group hierarchy, a Key Vault per scope, hub-spoke VNets with private endpoints, Private DNS Zones and peering.

By the end you will be able to draw the project boundaries the way a platform team draws them and defend why each line is where it is — the reuse it buys, the RBAC it enforces, the blast radius it contains. You will know when scale-set agents beat Microsoft-hosted (and when they don’t), how the autoscaling and ephemeral-VM lifecycle actually behaves, how the fleet’s hub placement lets a pipeline reach a private Azure SQL or a private Artifacts-less internal NuGet feed, and roughly what the whole thing costs per month. This is the overview blog of a six-part enterprise-CI/CD series; the PR, CI, CD, mobile and database pipelines each get their own deep dive. Here we build the stage on which all of them run.

What problem this solves

The pain a single-project, hosted-agent org hits is not abstract; it shows up as five recurring incidents. Reuse rots: the same hundred lines of build YAML live in ninety pipelines, so a single change — a new mandatory security scan, a bumped SDK version — is a ninety-pull-request migration nobody finishes, and the pipelines drift apart until “the build” means ninety subtly different things. Provenance evaporates: an app restores packages from wherever it can reach — public nuget.org, a team feed, a developer’s local cache — so the same binary has three possible origins and a supply-chain audit is impossible. Blast radius is the whole org: one project means one set of permissions, one set of service connections, one set of secure files; a compromised pipeline or an over-broad token can touch everything because there is no boundary to stop it. Agents become pets: that one VM under the desk has hand-installed tools nobody documented, no patching, no scaling, and when it dies on a Friday the entire org stops shipping. Private networking is impossible: Microsoft-hosted agents live on Microsoft’s network with public egress, so the day you lock your Azure SQL, Key Vault and Artifacts behind private endpoints — which every enterprise eventually must — the hosted agents can no longer reach them and every deploy fails with a timeout.

What breaks without the paved road is therefore consistency, traceability, isolation, reliability and reachability — the exact five properties an enterprise audit, a security review and an SRE on-call all depend on. Each one is solved by a deliberate structural decision: reuse by centralized templates, provenance by a single governed feed with upstream sources, blast radius by project boundaries with scoped RBAC, reliability by an autoscaled fleet with managed images, and reachability by hub placement on a private network. Miss any one and you are back to the incident list.

Who hits this: every organisation past roughly three teams or twenty pipelines. Below that, a single project and hosted agents are genuinely fine — the paved road is overhead you do not need yet. Above it, the cost of not having structure compounds: onboarding a new app goes from “copy the neighbour’s pipeline and pray” to “reference the templates, the feed and the modules — done in an afternoon,” and that delta is the entire value of platform engineering. The table below frames the field — every structural decision this article makes, the pain it removes, and the one mechanism that delivers it.

Decision Pain it removes Mechanism Owned by
Project per responsibility Org-wide blast radius; tangled RBAC Separate ADO projects (IaC, templates, packages, apps) Platform team
Repo per Terraform module Monolith state; can’t version a module One Git repo + semver tag per module Platform / module owners
Centralized YAML templates 90 copies of the same pipeline extends/template from a shared repo Platform team
Single governed Artifacts feed Untraceable package origins One org feed + upstream sources + feed enforcement Platform team
Shared VMSS agent fleet Pet build VMs; no scaling; no private reach Scale-set agents in an agent pool Platform team
Hub placement on private net Hosted agents can’t reach private PaaS VMSS in Hub VNet; spoke peering; private DNS Platform + network
CAF landing zone target Ungoverned subscriptions; no guardrails Management groups + Azure Policy + Key Vault per scope Cloud platform / governance

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with the Azure DevOps building blocks: an organisation contains projects; a project contains Repos (Git), Pipelines (YAML), Artifacts (feeds), Boards and Test Plans; pipelines run jobs on agents drawn from agent pools; and pipelines reach Azure through service connections (ideally workload-identity federation, not stored secrets). You should know basic Git branching strategies (this platform uses GitFlow), what a CI/CD pipeline does end to end, and the shape of Terraform modules with remote state. Familiarity with the Azure CAF landing zone and hub-spoke topology will make the networking sections land faster, and the Azure DevOps scale-set agents hardening deep dive is the security companion to the fleet we stand up here.

Where this fits: this is the platform-engineering overview — the altitude where you decide structure — and it sits above the individual pipelines that ride on it. The same idea, told as a maturity curve, is the DevOps architecting ladder from a single pipeline to a platform and, for the IaC side, the Terraform architecting ladder from a single module to a platform. The deep dives that consume this stage are the PR-gate pipeline, the CI pipeline (secrets from Key Vault with zero plaintext), the CD promotion pipeline (blue-green via slots), the mobile pipelines, and the Liquibase database pipeline — each its own article in this series.

A quick map of who owns which seam, so during an incident you call the right team:

Layer What lives here Who owns it What a failure here looks like
ADO organisation & projects Project structure, org policies, parallel jobs Platform team Wrong RBAC scope; no free agent capacity
Pipeline-templates repo Centralized extends/template YAML Platform team One template bug breaks every pipeline
IaC project (module repos) Terraform modules, versions, root state Platform / module owners Bad module tag breaks every consumer’s plan
Packages project (feeds) Azure Artifacts feeds, upstream sources Platform team Feed down or untraceable → CI restore fails
Application projects App repos, app pipelines, app slots App / dev teams App build/deploy fails (their code)
Agent fleet (VMSS) Scale-set agents, golden image, autoscale Platform team No agents → every pipeline queues forever
Hub-spoke network Hub VNet, peering, private endpoints, DNS Network team Agent can’t reach private PaaS → timeouts
CAF landing zone Mgmt groups, Azure Policy, Key Vault per scope Cloud platform / governance Policy denies a deploy; missing scope KV

Core concepts

Six mental models make every later decision obvious, so pin them down before the deep sections.

An organisation is a blast-radius boundary, and a project is the unit you draw it with. Azure DevOps gives you exactly two nesting levels that matter for isolation: the organisation (one billing and identity boundary, one set of parallel-job grants, one Microsoft Entra tenant binding) and the project (its own repos, pipelines, feeds, service connections, secure files, environments and security groups). Permissions, service connections and secrets do not automatically cross a project boundary — that is the whole point. So you put things in separate projects precisely when you want a wall between them: the team that can change a Terraform module should not, by default, be able to publish a NuGet package or deploy a web app. One project = one set of keys.

Reuse lives in three different planes, and you must not confuse them. Code reuse for infrastructure is a Terraform module (consumed by Git source + version). Code reuse for pipelines is a YAML template (consumed by extends/template). Reuse for binaries is an Azure Artifacts package (consumed by a feed restore). These are three separate reuse mechanisms with three separate homes — the IaC project, the templates project, the packages project — and a healthy platform keeps them strictly apart. A module is not a template; a template is not a package. Collapsing them (e.g. copy-pasting a module’s HCL into every root, or vendoring a package’s source into every app) is exactly the rot the structure exists to prevent.

Repo-per-module is what makes a module a product. A Terraform module in its own Git repo (main.tf, variables.tf, outputs.tf, locals.tf, .tpl templates) can be tagged with a semantic version (1.0, 2.0, 2.1), have its own PR gate and tests, have RBAC scoped to its owners, and be consumed at a pinned version by any number of root configurations via source = "git::https://...//?ref=2.1". A consumer that pins 2.1 is immune to a breaking change you make on main — they upgrade on their schedule. Put all modules in one monorepo and you lose every one of those properties: you cannot version a single module independently, a PR touches unrelated modules, and one team’s experiment shares state and blast radius with everyone’s production.

A template is a contract, and extends makes it mandatory. A pipeline-templates repo exposes reusable YAML in two flavours. A template include is opt-in reuse — a pipeline pulls in a stage or a set of steps it chooses. An extends template is governance — the consumer pipeline declares “I extend the platform template,” and the platform template decides the overall shape, can inject mandatory steps (a security scan, a provenance stamp) the consumer cannot remove, and can be locked so a pipeline that doesn’t extend it is rejected. The platform team owns these templates; app teams pass parameters. Because templates expand at compile time (the ${{ }} expression syntax runs before the run starts), the platform can enforce rules structurally, not just by hoping people follow a wiki.

The agent fleet is cattle, not a pet — and it lives where your private resources live. A Microsoft-hosted agent is a fresh, Microsoft-managed VM per job on Microsoft’s network; you get clean isolation and zero maintenance, but no custom image, limited power, capped minutes, and — critically — public egress only. A self-hosted scale-set agent is a VM the platform team owns, booted from a golden image you build, running in a VNet you choose, autoscaled by Azure DevOps against a VM Scale Set, and ephemeral (“delete after each use”) so every job gets a clean machine like the hosted ones but with your tools and your network reach. Placing that VMSS in the hub of a hub-spoke network is what lets a build agent open a private connection to Azure SQL, pull from a private Container Registry, or read a Key Vault that has its public endpoint disabled — none of which a hosted agent can do.

The landing zone is the governed target, not a free-for-all subscription. Pipelines do not deploy into “Azure”; they deploy into a CAF landing zone — a management-group hierarchy (Tenant Root → org → Landing Zone → Corporate → Corp Non-Production / Corp Production) where Azure Policy sets guardrails (allowed regions, required tags, deny-public-IP), each scope has its own Key Vault, and the network is hub-spoke with private endpoints to PaaS, Private DNS Zones for name resolution and VNet peering tying spokes to the hub. The pipeline’s service connection is scoped to a subscription under one of those management groups, and Policy will deny a deploy that breaks a guardrail before any resource is created. The platform’s job is to make the paved road and the landing zone fit together so a compliant deploy is the easy path.

The vocabulary in one table

The glossary at the end repeats these for lookup; this table is the mental model side by side.

Concept One-line definition Where it lives Why it matters here
Organisation Billing + identity + parallel-job boundary Azure DevOps top level One org; projects sit inside it
Project Repos + pipelines + feeds + RBAC unit Inside the org The blast-radius wall you draw
Terraform module Versioned infra building block One Git repo in the IaC project Reused by Git source + tag
Root config Composition that calls modules App or env repo Pins module versions; holds state
YAML template Reusable pipeline shape/steps Pipeline-templates repo extends (govern) / template (reuse)
Azure Artifacts feed Hosted package registry Packages project One governed origin + upstream
Upstream source Proxy to a public registry On the feed Caches nuget.org/npm/PyPI/Maven
Agent pool Named set of agents Org or project Pipelines target it by name
Scale-set agent Self-hosted agent on a VMSS Hub VNet Elastic, ephemeral, private-reachable
Golden image Pre-baked agent VM image Compute Gallery Every agent boots from it
Service connection Pipeline → Azure auth Per project Scoped to a subscription; use WIF
Landing zone Governed deploy target Mgmt-group hierarchy Where every pipeline ships to
Private endpoint Private IP for a PaaS service Spoke subnet What only hub agents can reach

Designing the organisation: one org, many single-responsibility projects

The first and most consequential decision is the project layout. A project in Azure DevOps is the strongest isolation boundary you get short of a whole new organisation: it has its own repos, its own pipeline library and templates, its own Artifacts feeds, its own service connections, its own secure files and variable groups, its own environments and approvals, and its own security groups and permissions. Things do not leak across that boundary unless you deliberately open a hole (a cross-project pipeline resource, a feed shared org-wide, a service connection shared to another project). So the design question is simply: what do you want walled off from what? The enterprise answer is four kinds of project, each owning exactly one responsibility.

The four projects and why each is its own wall:

Project Holds Consumed by Why a separate project (the wall it draws)
IaC Terraform module repos (one per module) + root configs App teams, platform Module change must not grant package-publish or app-deploy rights; isolate infra blast radius
Pipeline-templates Centralized reusable YAML (extends/template) Every pipeline in every project One template = org-wide reach; lock down who can edit the thing everyone runs
Packages Azure Artifacts feeds (Maven, NuGet, npm, Python) Every build that restores/publishes Single governed package origin; feed permissions independent of app RBAC
Application(s) App source repos + app pipelines (web, function, mobile) End users / production App teams own their code & deploys without touching shared platform assets

Why split IaC out (and not just “have a Terraform folder”)

The IaC project is where the platform team and module owners live. It holds the Terraform module repos — one Git repo per module, each with main.tf, variables.tf, outputs.tf, locals.tf and any .tpl templates — plus the root configurations that compose those modules into real environments and hold the azurerm remote state. Separating it from the application projects buys three things. First, RBAC: the people who can change how a VNet or a Key Vault is provisioned are a small, audited set, distinct from the dozens of app developers; you do not want a frontend developer able to alter the network module that every team depends on. Second, blast radius: a bad Terraform change can destroy infrastructure, so its pipelines, state and service connections sit behind their own wall with their own approvals. Third, lifecycle: infrastructure changes on a different cadence and through a different review (a platform CCB, not a feature sprint) than application code.

Why split pipeline-templates out

The templates project holds the centralized YAML every other pipeline extends. This is the highest-leverage and highest-risk repo in the organisation: a single change here propagates to every pipeline that references it. That is exactly why it is its own project with tightly restricted write access. App teams have read (they reference templates by repository resource) but not write; only the platform team merges changes, behind a PR gate with required reviewers. The payoff is enormous: add a mandatory Veracode scan to the build template once, and ninety pipelines get it on their next run with zero per-team PRs. The risk is symmetric — a typo in the template breaks ninety pipelines at once — which is why this project gets the strictest branch policies and the most testing.

Why split packages out

The packages project hosts the Azure Artifacts feeds: a Maven feed for the JVM apps, a NuGet feed for .NET, an npm feed for the frontends, a Python (PyPI) feed for the data/ML tooling — or one multi-protocol feed with views. Keeping feeds in their own project decouples package permissions from app permissions: who may publish a shared library is a platform decision, not something an app team grants itself. It also makes the feed the single governed origin for binaries — with upstream sources proxying the public registries so every external package is cached and recorded — which is the foundation of supply-chain traceability. CI pipelines enforce restoring only from this feed, so “where did this binary come from” always has one answer.

Why many application projects (and not one big app project)

Each application — or each closely-related group of apps owned by one team — gets its own project (or its own area within a project, depending on team size). This gives every app team their own repos, their own pipelines, their own deployment environments and approvals, and their own service connections, without the ability to touch the shared platform assets. A web-app team breaking their own pipeline cannot break the templates everyone uses; a mobile team’s signing certificates live in their secure files, not in a shared blast radius. The application projects are consumers — they reference the templates (read), restore from the feed (read/publish to app-scoped feeds), and call the modules (read) — and that consumer relationship is what the whole structure is built to make safe and repeatable.

What each project consumes from the others — the dependency graph in one table:

Application project needs… From project How it’s referenced Permission it needs there
Build/deploy pipeline shape Pipeline-templates resources.repositories + extends Read on the templates repo
Infra (VNet, KV, slots…) IaC Root config calls module by Git source + tag Read on module repos
Packages to restore Packages Feed nuget.config/.npmrc with feed URL Feed Reader (+ Publisher for its own libs)
Agents to run on (org-level pool) pool: name: vmss-linux Pool user (granted at org/project)
Azure to deploy into (its own) service connection azureSubscription: in a deploy task Service-connection user (in this project)

Organisation-level settings that apply across all projects

A few things live at the organisation level and are shared by every project: the agent pools (you want one shared fleet, so the pool is org-scoped and granted to projects), the parallel-job grants (the number of concurrent jobs you have bought — this is org billing), the Entra tenant binding and conditional-access policy, and org-wide policies (e.g. “disallow anonymous access,” “limit scope of service-connection creation”). Getting these right once is part of the paved road. The split between what is org-level and what is project-level:

Setting Org-level or project-level Why Who sets it
Agent pools (the VMSS fleet) Org-level, granted to projects One shared fleet, many consumers Platform team
Parallel jobs (concurrency you bought) Org-level (billing) Capacity is purchased per org Platform team
Entra tenant + conditional access Org-level One identity boundary Identity / platform
Project-scoped build identity Project-level (recommended) Limit a pipeline’s reach to its project Platform team
Service connections Project-level Scope Azure auth to the project’s subs App + platform
Secure files / variable groups Project-level Secrets isolated per project App + platform
Repos, pipelines, feeds, environments Project-level The whole point of a project Each team

Repo-per-module: structuring the Terraform IaC project

Inside the IaC project, the defining choice is one Git repo per Terraform module. This is not bureaucracy; it is what turns a module from “some shared HCL” into a versioned product with an owner, a contract and a release cadence. The reference platform’s modules each carry the same skeleton — main.tf (the resources), variables.tf (the inputs/contract), outputs.tf (what consumers read back), locals.tf (computed values, naming, tag merges) and .tpl templates (rendered config, e.g. a cloud-init or an app settings file) — and each is tagged with a semantic version so consumers pin exactly what they trust.

What each file in a module repo is for:

File Purpose Changes when Consumer-visible?
main.tf Declares the resources the module creates You add/alter a resource No (internal)
variables.tf The module’s input contract (typed, validated) You add/rename an input Yes — this is the API
outputs.tf Values consumers read back (IDs, FQDNs) You expose new outputs Yes — this is the API
locals.tf Naming, tag merges, computed values Internal logic changes No (internal)
*.tpl Templated files rendered via templatefile() The rendered config changes No (internal)
README.md Usage, inputs, outputs, examples The contract changes Yes — the docs
versions.tf Required Terraform + provider versions You bump a provider floor Indirectly (constraints)

Why repo-per-module beats the monorepo

The case is concrete. With a repo per module you get independent semantic versioningazurerm-keyvault can ship 2.1 while azurerm-vnet sits at 4.0, and a consumer upgrades each on its own schedule. You get isolated blast radius and state — a PR to one module cannot accidentally re-plan another, and one module’s experiment never shares state with another’s production. You get granular RBAC — the networking module’s owners are not automatically the database module’s owners. And you get focused PRs and reviews — a diff is about one module, reviewed by its owners, with that module’s own tests and PR gate. The monorepo trades all of that away for the single convenience of “one clone.”

Repo-per-module versus monorepo, decision-grade:

Dimension Repo per module Monorepo of modules Winner for an enterprise
Independent versioning Each module tags its own semver All modules share one tag/commit Repo-per-module
Blast radius of a PR One module only Any module (shared history) Repo-per-module
State isolation Natural — separate consumers Risk of shared/tangled state Repo-per-module
RBAC granularity Per-module owners Whole-repo permissions Repo-per-module
PR review focus One module, its owners Mixed, noisy diffs Repo-per-module
Cross-module refactor Multiple coordinated PRs One PR Monorepo (rare case)
Discovery / “where is X” Need an index/registry One place to browse Monorepo (mitigable)
Clone/setup friction Many small repos One clone Monorepo (minor)

How a root config consumes a module (Git source + version pin)

A root configuration — the thing that actually composes modules into an environment and holds state — references each module by Git source and a ref tag. Pinning to a tag (not a branch) is the rule: it makes the consumer immune to upstream changes until they choose to bump.

# root/non-prod/main.tf — a Non-Prod composition pinning module versions
module "network" {
  source = "git::https://dev.azure.com/acme/IaC/_git/azurerm-vnet//?ref=4.0"

  resource_group_name = local.rg
  location            = "centralindia"
  address_space       = ["10.20.0.0/16"]
  subnets             = local.subnets
  tags                = local.tags
}

module "keyvault" {
  source = "git::https://dev.azure.com/acme/IaC/_git/azurerm-keyvault//?ref=2.1"

  name                = "kv-corp-nonprod"
  resource_group_name = local.rg
  location            = "centralindia"
  # private-endpoint into the spoke; public access disabled (CAF guardrail)
  public_network_access_enabled = false
  private_endpoint_subnet_id    = module.network.subnet_ids["snet-pe"]
  tags                          = local.tags
}

The backend keeps azurerm remote state in a storage account (one state per root/environment), with state locking via the blob lease — and Non-Prod and Prod are separate root configs with separate state and separate apply pipelines, so a Prod apply can never be triggered by a Non-Prod change.

# root/non-prod/backend.tf — azurerm remote state, isolated per environment
terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateacme"
    container_name       = "nonprod"          # prod uses a different container
    key                  = "corp-nonprod.tfstate"
  }
}

How module versions flow from author to consumer:

Stage Action Mechanism Result
Author Change azurerm-keyvault, open PR Module PR gate (fmt, validate, tests) Reviewed change on a branch
Release Merge + tag 2.2 git tag 2.2 (semver) Immutable, consumable version
Consume Root bumps ?ref=2.1?ref=2.2 Git source pin in module block Opt-in upgrade, one consumer at a time
Plan Non-Prod apply pipeline runs plan terraform init && plan Diff reviewed before apply
Apply Approve → apply to Non-Prod Gated apply pipeline Infra changes in Non-Prod only
Promote Repeat ref bump in Prod root Separate Prod state + pipeline Same version, promoted deliberately

Versioning discipline follows standard semver, mapped to Terraform module reality:

Change to a module Semver bump Example Consumer impact
New optional input (defaulted) Minor (2.12.2) Add tags with a default None until they use it
New output Minor Expose private_ip None; available if wanted
Bug fix, no contract change Patch (2.1.02.1.1) Fix a locals typo Safe to take
Rename/remove an input Major (2.x3.0) Drop a deprecated var Breaking — they must edit
Change a default that alters resources Major Default SKU change Breaking — re-plan/replace

The deeper mechanics of authoring and consuming modules — input validation, for_each over collections, nested modules, the registry pattern — are their own topic in Terraform module design, composition & versioning; the remote-state-at-scale concerns (state-per-environment, locking, partial config) live in Terraform remote state at scale.

Centralized pipeline templates: write the build once, run it ninety times

The pipeline-templates project exists so that “the build” is one thing, defined once, that every pipeline inherits. Azure DevOps gives you two reuse primitives, and the platform uses both deliberately. A template include stitches reusable fragments — a stage, a job, a set of steps — into a pipeline that chooses to pull them in (opt-in reuse). An extends template flips control: the consumer pipeline declares it extends the platform template, and the platform template owns the overall shape and can inject mandatory steps the consumer cannot remove (governance). The reference platform makes the PR, CI and CD pipelines all extends a platform template, so a security scan, a feed-enforcement step or a provenance stamp is structurally present in every run, not a thing teams remember to add.

The two reuse primitives, side by side:

Aspect template (include) extends (governance)
Who controls the overall shape The consumer pipeline The platform template
Can it inject mandatory steps? No — consumer assembles Yes — consumer can’t remove them
Typical use Reuse a job/steps fragment Enforce a whole pipeline shape
Consumer writes - template: x.yml@templates extends: { template: ci.yml@templates }
Enforceable org-wide? By convention Yes — can require extends
Best for Shared steps, DRY PR/CI/CD governance, compliance

Referencing the templates repo and extending it

A consumer pipeline declares the templates repo as a repository resource, then extends a template from it, passing parameters. The app team writes a handful of lines; the platform owns the hundred lines behind them.

# azure-pipelines.yml in an APPLICATION repo — extends the platform CI template
resources:
  repositories:
    - repository: templates
      type: git
      name: Templates/pipeline-templates   # Project/Repo in the same org
      ref: refs/tags/v3.4.0                 # pin the template version

trigger:
  branches: { include: [ development, release/*, main ] }

extends:
  template: ci/build-deploy.yml@templates
  parameters:
    appName: shop-web
    language: dotnet
    feed: acme-internal
    deployEnvironments: [ Dev, SIT, QA, Staging, UAT, PreProd, Production ]

Pinning the template with ref: refs/tags/v3.4.0 is the same discipline as pinning a Terraform module: the consumer takes platform changes on its own schedule, and the platform can ship v3.5.0 without breaking anyone who hasn’t bumped. Leaving ref off (floating on the default branch) means every platform merge instantly hits every pipeline — fast propagation, zero safety; choose it consciously.

The platform template: parameters, compile-time expressions, mandatory steps

The template itself uses typed parameters and ${{ }} compile-time expressions (these expand before the run starts, so they can drive structure — loop over environments, conditionally include a stage), and it bakes in the steps the platform mandates.

# ci/build-deploy.yml in the TEMPLATES repo (abridged)
parameters:
  - name: appName
    type: string
  - name: language
    type: string
    values: [ dotnet, node, python, java ]
  - name: feed
    type: string
  - name: deployEnvironments
    type: object
    default: []

stages:
  - stage: Build
    pool:
      name: vmss-linux          # the shared self-hosted fleet, by name
    jobs:
      - job: build
        steps:
          - template: ../steps/restore-internal-feed.yml@templates   # feed enforcement (mandatory)
            parameters: { feed: ${{ parameters.feed }} }
          - template: ../steps/build-${{ parameters.language }}.yml@templates
          - template: ../steps/veracode-sca.yml@templates            # SCA scan (mandatory)
          - template: ../steps/publish-artifact.yml@templates

  # Compile-time loop: one deploy stage per environment, generated from the parameter
  - ${{ each env in parameters.deployEnvironments }}:
      - stage: Deploy_${{ env }}
        dependsOn: Build
        jobs:
          - deployment: deploy
            environment: ${{ env }}        # ADO Environment → checks/approvals attach here
            strategy:
              runOnce:
                deploy:
                  steps:
                    - template: ../steps/deploy-appservice-slot.yml@templates
                      parameters: { appName: ${{ parameters.appName }}, env: ${{ env }} }

Two things make this powerful and safe. The restore-internal-feed and veracode-sca steps are inside the template, so a consumer that extends it cannot drop them — governance by structure. And the ${{ each env in … }} loop generates one deploy stage per environment at compile time, so the long Dev→SIT→QA→Staging→UAT→PreProd→Production chain is one loop in the template, not seven copy-pasted stages in every app. The deeper template patterns — step/job/stage templates, extends with required parameters, template expressions and conditionals — are the subject of the Azure DevOps reusable YAML template library and the multistage environments-and-approvals deep dive.

Where each kind of logic belongs — the contract between platform and app team:

Concern Lives in the template (platform) Lives in the app pipeline (team)
Mandatory security scans Yes — injected, unremovable No
Feed-enforcement restore Yes No
Overall stage shape (Build→Deploy×N) Yes No
Which environments to deploy to Default, overridable Passed as a parameter
App name / language / feed Parameterised Supplied as parameters
App-specific build quirks Optional hook step Yes, via a parameter or a hook
Approvals / checks per environment On the Environment object No (attached to ADO Environments)

Parameters versus variables — a constant source of confusion, settled:

parameters variables
Evaluated Compile time (${{ }}) Runtime ($( )) and $[ ]
Can shape structure (loops, conditions) Yes No
Typed (string/number/boolean/object/step) Yes Strings only
Settable at queue time Yes (runtime parameters) Via UI/variable groups
Right for template inputs Yes No
Right for secrets No Yes (from a variable group / Key Vault)

The packages project: one governed Azure Artifacts feed with upstream sources

The packages project hosts Azure Artifacts — Azure DevOps’s package registry — and its job is to be the single, governed origin for every binary the platform builds or consumes. It serves the four ecosystems the apps use: NuGet (.NET), npm (frontends), Maven (JVM) and Python/PyPI (data and ML tooling). You can run one feed with multiple protocols or one feed per ecosystem; the enterprise pattern is usually one organisation feed (e.g. acme-internal) that all four protocols publish to and restore from, plus app-scoped feeds where a team needs a private space.

The killer feature is upstream sources. A feed with upstream sources configured will, on a cache miss, fetch the requested package from the public registry, serve it, and save a copy in the feed — so every external dependency you have ever used is recorded and re-served from one place. That is the mechanism that makes provenance real: builds restore from acme-internal, the feed either has the package (an internal library or a previously-cached public one) or pulls it once from upstream and remembers it, and “where did this binary come from” always resolves to the feed.

The feed’s two jobs and how upstream sources deliver them:

Capability What it does Why it matters How it’s configured
Host internal packages Your shared libraries live here Reuse of your binaries, versioned Publish from CI to the feed
Proxy/cache public registries First request fetches + caches from upstream Provenance + resilience to upstream outages Upstream sources on the feed
Single restore origin Apps point at one feed URL Traceability; no “wherever I can reach” nuget.config / .npmrc / pip.conf
Retention Prune unused versions Control storage cost Retention policies on the feed
Views (@local, @prerelease, @release) Promote quality levels Ship only vetted versions downstream Feed views + promotion

Per ecosystem, the upstream a feed proxies and the client config that points at it:

Ecosystem Public upstream proxied Client config file Restore command
NuGet (.NET) nuget.org nuget.config (<packageSources>) dotnet restore
npm (frontend) registry.npmjs.org .npmrc (registry=) npm ci
Maven (JVM) Maven Central settings.xml (<repositories>) mvn -s settings.xml
Python (PyPI) pypi.org pip.conf (index-url) pip install -r

Internal-feed enforcement: the rule that makes provenance non-optional

Hosting a feed is not enough — a build can still bypass it and restore straight from nuget.org if its config allows. Internal-feed enforcement is the practice (baked into the CI template’s restore step) of ensuring the build resolves packages only through the feed: the client config lists the feed as the single source, public registries are reached only via the feed’s upstream, and the restore authenticates to the feed with the pipeline’s identity. The reference platform’s CI pipeline does this in a mandatory template step so no app team can quietly restore from the public internet.

<!-- nuget.config committed in the app repo — the feed is the ONLY source -->
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />  <!-- removes nuget.org as a direct source; upstream handles public packages -->
    <add key="acme-internal"
         value="https://pkgs.dev.azure.com/acme/Packages/_packaging/acme-internal/nuget/v3/index.json" />
  </packageSources>
</configuration>
# steps/restore-internal-feed.yml (TEMPLATES repo) — mandatory, injected by extends
steps:
  - task: NuGetAuthenticate@1                 # auth the agent to the feed with the pipeline identity
  - script: dotnet restore --configfile nuget.config
    displayName: 'Restore (internal feed only)'

The <clear/> element is the load-bearing line: it removes any inherited public source, so the feed (and only the feed, with public packages arriving through its upstream) can satisfy the restore. The companion practices — feed views to gate quality, retention to control cost, and immutability of published versions — are covered in Azure DevOps Artifacts feeds, upstream sources & versioning and the broader artifact registry management overview.

What internal-feed enforcement buys, and the failure it prevents:

Without enforcement With enforcement Risk removed
Builds restore from nuget.org directly Builds restore only via the feed Untraceable package origin
Public outage breaks the build Feed serves the cached copy Build resilience
A typosquatted package can slip in Feed-mediated, scannable, auditable Supply-chain attack surface
“It worked on my machine” drift One source of truth for versions Reproducibility
Veracode SCA sees partial inventory Full dependency inventory in one place Incomplete security scanning

Feed RBAC — who can do what — kept separate from app permissions by virtue of living in the packages project:

Feed role Can Typical holder
Reader Restore/consume packages Every build identity
Collaborator Restore + save from upstream CI build identities
Contributor Publish package versions Library-owning teams / CI
Owner Manage feed, upstreams, retention, permissions Platform team

The centralized agent fleet: scale-set agents on a VM Scale Set

Every pipeline in every project runs on one shared self-hosted agent fleet: an Azure DevOps agent pool backed by an Azure VM Scale Set (VMSS), placed in the hub of the hub-spoke network. Azure DevOps manages the scaling of that VMSS for you (you do not write autoscale rules; the service adds and removes VMs based on queued jobs), each agent boots from a golden image the platform builds, and agents are ephemeral — configured to be deleted after each job so every run gets a clean machine. This is the single most important infrastructure decision after the project layout, because it determines what your pipelines can reach, how fast they start, what they cost, and how much you maintain.

Why self-hosted scale-set agents over Microsoft-hosted

Microsoft-hosted agents are genuinely excellent for many teams: a fresh, clean, Microsoft-patched VM per job, zero maintenance, broad pre-installed toolsets. You move off them for specific, concrete reasons. Private-network reach is the big one: hosted agents have public egress only, so the moment your Key Vault, Azure SQL, Storage and Artifacts sit behind private endpoints (the CAF default), hosted agents time out and your deploys fail — a self-hosted agent in the hub reaches them over the private network. Custom image: bake your exact SDK matrix, internal CA certs, and tooling once into a golden image instead of installing on every run. Power and time: pick the VM SKU you want (more vCPU/RAM, faster disk) and run jobs longer than the hosted timeout. Cost at scale: past a certain concurrency, owning a right-sized, autoscaling fleet that scales to zero off-hours is cheaper than buying many hosted parallel jobs. Caching: a warm Docker layer cache or package cache on a longer-lived image cuts build times.

The decision, made honestly — when each wins:

Need Microsoft-hosted Self-hosted scale-set (VMSS) Pick
Reach private endpoints / private PaaS No (public egress only) Yes (hub VNet) Scale-set
Zero infrastructure maintenance Yes You own image + fleet Hosted
Custom tools / SDK matrix / internal CAs Limited (install per run) Yes (golden image) Scale-set
Clean VM per job (isolation) Yes Yes (ephemeral “delete after use”) Tie
Large/long builds (CPU, RAM, time) Capped SKU + timeout Yes (any SKU) Scale-set
Lowest effort for a small team Yes Overhead not worth it Hosted
Cost at high concurrency Per-parallel-job, always on Scale to zero off-hours Scale-set (at scale)
Warm caches (Docker layers, packages) Cold each run Yes (image/cache disk) Scale-set

A focused comparison of the three agent models you will weigh:

Property Microsoft-hosted Scale-set agents (VMSS) Plain self-hosted (static VM)
Who manages capacity Microsoft Azure DevOps scales the VMSS You (manual)
Clean machine per job Yes Yes (ephemeral) No (state accumulates)
Network Microsoft public Your VNet (hub) Your VNet
Image Microsoft-maintained Your golden image Hand-built (pet)
Scales to zero N/A Yes (idle 0) No
Patching Microsoft You (rebuild image) You (in place — drifts)
Best for Small/medium, public Enterprise platform Legacy / special hardware

How Azure DevOps scales the VMSS (and why you don’t write autoscale rules)

A common misconception is that you configure VMSS autoscale rules (CPU thresholds) for agents. You don’t — and you should disable VMSS’s own autoscale on this scale set. Azure DevOps owns the scaling of an agent-pool VMSS: you register the scale set as an agent pool and give the pool two numbers — a maximum number of agents and a number of agents to keep on standby (idle/spare). Azure DevOps then watches the job queue and adds VMs up to the maximum when jobs are waiting, and removes idle VMs down to the standby count (which can be 0) when they aren’t. Because agents are ephemeral, a VM that finishes a job is destroyed and a fresh one is created to maintain the standby buffer — so you trade a little cost (the standby VMs) for fast job starts (no cold boot on the first queued job).

The pool’s scaling knobs — the entire control surface:

Setting What it controls Typical value Trade-off if too high / too low
Maximum number of agents Hard ceiling on fleet size 20–50 Too low → jobs queue; too high → runaway cost
Number of agents to keep on standby Warm spare VMs ready for instant pickup 1–3 (0 off-hours) Higher = faster starts, more idle cost
Delete agent after each use Ephemeral clean machine per job Enabled Off → state/secrets accumulate across jobs
Save uncached image / re-image Whether VMs are recreated from base Recreate Reuse risks drift; recreate is clean
Time to live (idle) How long an idle agent lingers before removal Short Longer = warmer, costlier
VMSS own autoscale (should be off — ADO scales it) Disabled On → fights Azure DevOps’s scaling
# Register an existing VMSS as an Azure DevOps elastic agent pool (az devops CLI)
az pipelines pool create --name vmss-linux \
  --vmss-resource-id $(az vmss show -g rg-hub-agents -n vmss-ado-linux --query id -o tsv) \
  --vmss-os-type Linux \
  --max-capacity 30 \
  --desired-idle 2 \
  --recycle-after-each-use true \
  --org https://dev.azure.com/acme

--recycle-after-each-use true is the ephemeral flag — each agent runs exactly one job, then the VM is torn down and a clean one takes its place. --desired-idle 2 keeps two warm agents so the first queued job doesn’t wait for a boot; --max-capacity 30 caps spend.

The golden image: what every agent boots from

An agent is only as good as its image. The platform builds a golden image — a VM image with the OS, the agent prerequisites, the SDK/tool matrix (the runtimes your builds need), internal CA certificates, the Azure CLI, Terraform, language toolchains, Docker, and the Azure Pipelines agent itself (or it self-installs on first boot) — published to an Azure Compute Gallery (Shared Image Gallery), and points the VMSS at it. You rebuild the image on a cadence (monthly, or when a CVE drops) using a tool like Packer or Azure VM Image Builder, version it, and roll the VMSS to the new version. Because agents are ephemeral and recreated from the image, patching means publishing a new image — there is no in-place drift to manage, which is exactly why this beats hand-patched static VMs.

What goes in the image and why:

Layer Examples Why bake it (vs install per run)
Base OS (hardened) Ubuntu LTS / Windows Server, CIS-tuned Consistent, patched, compliant start
Agent + prereqs Pipelines agent, git, jq, unzip Agent comes up instantly
Cloud tooling az CLI, Terraform, kubelogin Every infra/deploy job needs them
Language toolchains .NET SDK, Node, Python, JDK, Maven Avoid per-run installs (slow, flaky)
Container tooling Docker / BuildKit, registry creds helper Builds + scans need it; warm layer cache
Internal trust Corporate root CA, internal feed certs Reach internal TLS endpoints
Scanners/agents Veracode wrapper, Datadog agent Mandatory steps run without setup
# Point the VMSS at a versioned image from the Compute Gallery (Terraform, abridged)
resource "azurerm_linux_virtual_machine_scale_set" "ado" {
  name                = "vmss-ado-linux"
  resource_group_name = "rg-hub-agents"
  location            = "centralindia"
  sku                 = "Standard_D4s_v5"   # 4 vCPU / 16 GB — sized for builds
  instances           = 0                    # ADO drives the count; start at 0

  source_image_id = azurerm_shared_image_version.agent.id   # the golden image version

  # NIC into the HUB VNet so agents reach private endpoints across peering
  network_interface {
    name    = "nic"
    primary = true
    ip_configuration {
      name      = "ipcfg"
      primary   = true
      subnet_id = azurerm_subnet.hub_agents.id
    }
  }
  # disable VMSS-native autoscale; Azure DevOps manages scaling
  upgrade_mode = "Manual"
}

The full security treatment of these agents — image hardening, the ephemeral lifecycle as a security control, identity and secret handling, and locking the fleet down — is the dedicated scale-set agents hardening article; the same ephemeral-autoscaling pattern on Kubernetes is covered in self-hosted runners with autoscaling and ephemeral Kubernetes.

Hub placement and private networking: what only this fleet can reach

The fleet lives in a subnet of the Hub VNet. Spokes (the application landing-zone VNets) peer to the hub, and the PaaS services the apps use — Key Vault, Azure SQL/MySQL/PostgreSQL, Storage, the Container Registry, the Artifacts-backed internal feeds — are fronted by private endpoints in spoke subnets with Private DNS Zones resolving their names to private IPs. Because the agent sits in the hub and the hub peers to every spoke, an agent can open a private connection to any of those services; because the hub uses the Private DNS Zones (linked to the hub VNet), kv-corp-prod.vault.azure.net resolves to the private IP, not the public one. A Microsoft-hosted agent, on Microsoft’s public network, resolves that same name to the public endpoint — which is disabled — and times out. Hub placement is the difference between “deploy works” and “deploy hangs for four minutes then fails.”

The private-reach matrix — what the agent talks to and how:

Target Reached via DNS resolution Why hosted agents fail
Key Vault (secrets at CI time) Private endpoint in spoke Private DNS Zone privatelink.vaultcore.azure.net Public KV endpoint disabled
Azure SQL / MySQL / PostgreSQL Private endpoint privatelink.database.windows.net etc. DB firewall blocks public
Container Registry (image pull) Private endpoint privatelink.azurecr.io Registry public access off
Storage (state, artifacts) Private endpoint privatelink.blob.core.windows.net Storage firewall blocks public
App Service deploy (private) Private endpoint / VNet privatelink.azurewebsites.net Site private-access only
Internal package feed (private) Hub egress / private Internal DNS Reaches feed but not private PaaS
# Hub VNet + agent subnet + peering to a spoke (Terraform, abridged)
resource "azurerm_subnet" "hub_agents" {
  name                 = "snet-ado-agents"
  resource_group_name  = "rg-hub-network"
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = ["10.0.4.0/24"]
}

resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                      = "hub-to-corp-prod"
  resource_group_name       = "rg-hub-network"
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.spoke_corp_prod.id
  allow_forwarded_traffic   = true
  allow_virtual_network_access = true
}

The networking design behind this — when to use hub-spoke versus Virtual WAN, how private endpoints and Private DNS scale, the peering and DNS gotchas — is covered in hub-spoke vs Virtual WAN enterprise topology, private endpoints and DNS at scale, and Azure Private Link with Private DNS for PaaS; peering specifics are in VNet peering, gateway transit & global peering.

Ephemeral agents and clean-machine guarantees

“Delete agent after each use” gives self-hosted agents the property people love about hosted ones: no state survives a job. A leaked secret written to disk, a poisoned package cache, a left-behind credential — all gone when the VM is destroyed after the job. The cost is that you can’t rely on a warm cache across jobs on the same VM (each VM does one job); you get warmth from the image (pre-baked tools) and from an external cache (a pipeline cache step, a registry pull-through) instead. For a security-sensitive platform this trade is almost always correct: clean machines are a control, not a nicety.

Ephemeral vs persistent agents — the trade in full:

Property Ephemeral (delete after use) Persistent (reused VM)
Secrets/state across jobs None survive (control) Accumulate (risk)
Cache warmth across jobs None (use image/external cache) Warm (faster, riskier)
Drift over time None (recreated) Accumulates
Cost Slightly higher (recreate VMs) Lower (reuse)
Job start latency Standby buffer hides it Instant if idle VM exists
Right for Enterprise / regulated Dev sandboxes, special cases

The deploy target: a CAF landing zone with a Key Vault per scope

Pipelines deploy into a Cloud Adoption Framework (CAF) landing zone, and understanding its shape is part of the paved road because Azure Policy in that hierarchy will accept or reject what your pipeline tries to create. The landing zone is a management-group hierarchy: Tenant Root at the top, then an org management group, then a Landing Zone group, then Corporate, splitting into Corp Non-Production and Corp Production. Subscriptions live under those leaf groups, and Azure Policy assigned at each level cascades down — allowed regions, mandatory tags, deny public IPs, require private endpoints, enforce diagnostic settings. A pipeline’s service connection is scoped to a subscription under one of those groups, so a Non-Prod pipeline literally cannot deploy to a Prod subscription, and a deploy that violates a guardrail is denied by Policy before any resource exists.

The management-group hierarchy and what governs each level:

Scope (top → leaf) Purpose Policy assigned here (examples) Key Vault at this scope
Tenant Root Org-wide root Break-glass, root guardrails
org The enterprise Allowed regions, required tags
Landing Zone Where workloads land Deny public IP, require PE, diag settings
Corporate Corp-connected workloads Network/firewall baselines
Corp Non-Production Dev/SIT/QA/Staging/UAT subs Relaxed cost guardrails, non-prod SKUs KV (non-prod secrets)
Corp Production Pre-Prod/Production subs Strict change control, prod SKUs KV (prod secrets)

A Key Vault per scope is the rule: non-prod secrets live in a non-prod Key Vault, prod secrets in a prod Key Vault, each behind a private endpoint with public access disabled, each with access granted to exactly the identities that need it (the CI pipeline’s managed identity for that environment, the apps in that scope). Separating the vaults means a non-prod pipeline credential can never read a production secret — the same blast-radius logic as the project boundaries, applied to secrets. The CI pipeline fetches its secrets from the scope’s Key Vault at run time (the agent reaches the private endpoint from the hub), so nothing is stored in plaintext in the pipeline; that pattern is the subject of secret management with Key Vault and managed identity, zero plaintext.

Key Vault per scope — what each holds and who reads it:

Key Vault Scope Holds Read by
kv-corp-nonprod Corp Non-Production Dev/SIT/QA/Staging/UAT secrets Non-prod CI identity + non-prod apps
kv-corp-prod Corp Production Pre-Prod/Production secrets Prod CD identity + prod apps
Per-scope, private Always Connection strings, API keys, certs Only that scope’s identities

The hub-spoke network underneath ties it together: the Hub VNet holds shared services (the agent VMSS, and typically a firewall/DNS), each spoke VNet is a workload’s network with subnets, private endpoints to its PaaS, and Private DNS Zones for resolution, and VNet peering connects each spoke to the hub. The full landing-zone design — accelerators, policy initiatives, the management-group blueprint — is the subject of the CAF landing zones deep dive and the enterprise-scale landing zone article; the management-group design itself is in management-group hierarchy design, and policy-at-scale in Azure Policy governance at scale.

How the pipeline and the landing zone meet — the seam:

Pipeline element Landing-zone counterpart Effect
Service connection (Non-Prod) Subscription under Corp Non-Production Can only touch non-prod
Service connection (Prod) Subscription under Corp Production Can only touch prod; gated
Deploy task creates a resource Azure Policy at the scope Denied if it breaks a guardrail
CI secret fetch Key Vault at the scope (private) Agent reaches it from the hub
App network Spoke VNet + private endpoints Agent reaches via hub peering

Architecture at a glance

Read the platform left to right and the whole paved road falls into place. On the left is the Azure DevOps organisation, carved into single-responsibility projects: the IaC project (Terraform modules, one repo per module, plus root configs and remote state), the pipeline-templates project (the centralized YAML every pipeline extends), the packages project (the Azure Artifacts feed with upstream sources), and the application projects (web, function and mobile repos and pipelines). These projects feed a stream of pipeline runs — PR-gate, CI and CD — and every one of those runs is dispatched to the same place: the shared agent pool. That pool is the bridge to the next zone.

In the centre sits the agent fleet — Azure DevOps scale-set agents on a VM Scale Set, ephemeral and autoscaled, booting from a golden image in a Compute Gallery — and crucially it lives in the Hub VNet. From the hub, peered to the spokes, the agents reach the right of the diagram: the CAF landing zone, where the management-group hierarchy (Corp Non-Production / Corp Production) governs a set of subscriptions, each scope has its Key Vault behind a private endpoint, and the spoke VNets host the app’s App Service / Functions and databases (Azure SQL / MySQL / PostgreSQL) reachable only over private endpoints with Private DNS. Follow the arrows: code and config flow from the projects into pipeline runs; runs flow onto the fleet; the fleet, from the hub, deploys across peering into the landing zone — and reads each scope’s Key Vault privately on the way. The numbered badges mark the load-bearing seams the rest of this article has detailed.

Left-to-right enterprise Azure DevOps platform architecture: an Azure DevOps organisation split into IaC (repo-per-Terraform-module), pipeline-templates, packages (Azure Artifacts feed with upstream sources) and application projects on the left; their PR/CI/CD pipeline runs dispatched to a shared centralized agent pool; a self-hosted VM Scale Set agent fleet (ephemeral, autoscaled, golden image) placed in the Hub VNet in the centre; and on the right a CAF landing zone with a management-group hierarchy (Corp Non-Production and Corp Production), a Key Vault per scope behind private endpoints, and spoke VNets running App Service, Functions and Azure SQL/MySQL/PostgreSQL reachable only via private endpoints and Private DNS — with numbered badges on the repo-per-module boundary, the extends-template governance, the internal-feed enforcement, the ephemeral VMSS agents, the hub private-endpoint reach, and the Key Vault-per-scope isolation.

Real-world scenario: a retail platform onboards its eighth team

A mid-sized retail company — call it NorthRetail — runs an e-commerce stack on Azure: a storefront web app, a catalogue API, a payments function app, an Android and an iOS app, and a MySQL-backed inventory service. They started, as everyone does, with one Azure DevOps project, the free Microsoft-hosted agents, and packages restored straight from nuget.org and npmjs. By the time a seventh team joined, the cracks were structural: the build YAML existed in eleven near-identical copies, a security audit could not establish where the payment SDK binary came from, and — the breaking point — the platform team locked Key Vault and Azure MySQL behind private endpoints to pass a PCI review, and every hosted-agent deploy began failing with dial tcp … i/o timeout. The hosted agents simply could not reach the now-private database and vault.

The platform team re-cut the organisation along the paved-road lines over a quarter. They created four projects: IaC (they split their one giant Terraform configuration into eight module repos — azurerm-vnet, azurerm-keyvault, azurerm-mysql, azurerm-appservice, azurerm-function, azurerm-frontdoor, azurerm-acr, azurerm-monitoring — each tagged, each owned), pipeline-templates (one extends template for PR, CI and CD, with Veracode SCA and internal-feed restore baked in as unremovable steps), packages (one acme-internal feed with NuGet/npm/Maven/Python upstream sources; <clear/> in every nuget.config), and per-team application projects. Then they stood up the agent fleet: a Standard_D4s_v5 VMSS in the hub subnet, registered as an org pool vmss-linux with max 30 / idle 2 / delete-after-each-use, booting from a Packer-built golden image carrying the .NET/Node/Python/JDK matrix, the internal CA, Terraform and the Veracode wrapper.

The day they pointed pipelines at the new pool, the private-endpoint deploys that had been timing out just worked — the agents resolved kv-corp-prod.vault.azure.net to its private IP through the hub-linked Private DNS Zone and read the secret over peering. The eleven copies of build YAML collapsed into one extends; when the security team mandated a new container scan two weeks later, the platform added it to the template once and all thirty-odd pipelines inherited it on their next run. The supply-chain audit that had been impossible became a single feed export. The numbers that mattered: onboarding the eighth team — the storefront-mobile squad — took one afternoon (reference the templates, add a nuget.config with the feed, pin the modules they needed) versus the two weeks the seventh team had taken under the old copy-paste regime. The fleet scaled to zero overnight, so the off-hours bill was the standby buffer alone. The one thing that went wrong: the first golden image shipped without the corporate root CA, so the first builds failed TLS to the internal feed with unable to get local issuer certificate — a thirty-minute fix (add the CA to the image, republish, roll the VMSS) that taught them to treat the image as a versioned, tested artifact like any other.

Advantages and disadvantages

The paved-road platform is not free — it is a real investment that pays back at scale. The honest two-column view:

Advantages Disadvantages
One change to a template/module/feed propagates everywhere Up-front design + migration cost (a quarter, not a sprint)
Project boundaries contain blast radius and scope RBAC More moving parts to learn (projects, pools, images)
Single feed = traceable, resilient supply chain Feed/upstream is a dependency to operate
VMSS agents reach private endpoints hosted agents can’t You own the golden image and its patch cadence
Ephemeral agents = clean machine + no drift Slightly higher compute cost than reused VMs
Scale to zero off-hours controls cost Standby buffer and image storage have a baseline cost
Repo-per-module = independent versioning + focused PRs Many small repos need a discovery index/registry
CAF landing zone enforces guardrails before deploy Policy can deny a deploy you didn’t expect (learning curve)
Onboarding a new app drops to an afternoon Below ~3 teams, the structure is overhead you don’t need

When each matters: the reuse and propagation advantages dominate the more teams and pipelines you have — at ninety pipelines, a one-PR template change is transformational; at three pipelines, it’s barely worth the indirection. The private-network reach advantage is binary and non-negotiable the moment your PaaS goes private — there is no hosted-agent workaround, so for any enterprise heading toward private endpoints the VMSS fleet is mandatory, not optional. The blast-radius and RBAC advantages matter most under audit and after the first security incident, when “who could have touched this” needs a crisp answer. The disadvantages are mostly front-loaded: the design and migration cost is paid once, the operational cost (image cadence, feed care) is ongoing but modest, and the “overhead below three teams” caveat is a genuine reason to not build this prematurely.

Hands-on lab: stand up the skeleton of the platform

This lab builds a minimal but real version of the platform: an org with the four project types, a feed with upstream sources, a templates repo a pipeline extends, and a VMSS agent pool. It uses the Azure DevOps CLI (az devops) and az. You need an Azure DevOps organisation, an Azure subscription, and az with the azure-devops extension (az extension add --name azure-devops). Costs are minimal if you keep the VMSS at idle 0 and delete it at the end.

Step 1 — Set defaults and create the four projects.

az devops configure --defaults organization=https://dev.azure.com/<yourorg>
for p in IaC Templates Packages Shop-Web; do
  az devops project create --name "$p" --visibility private \
    --query "{name:name,id:id}" -o table
done

Expected: four projects listed. These are your blast-radius boundaries.

Step 2 — Create the governed feed with upstream sources.

# A project-scoped feed in the Packages project, with public upstreams enabled
az artifacts universal  # (verify the extension is present)
az devops invoke --area packaging --resource feeds --route-parameters project=Packages \
  --http-method POST --in-file feed.json   # feed.json defines name + upstreamEnabled:true

If you prefer the portal for the feed: Packages project → Artifacts → Create Feed → enable “Use packages from public sources through this feed” (that switch is the upstream-sources toggle). Expected: a feed acme-internal that proxies nuget.org/npmjs/PyPI/Maven on first request.

Step 3 — Put a template in the Templates repo. Create ci/build.yml in the Templates project’s repo:

# ci/build.yml — a tiny extends template with a mandatory step
parameters:
  - name: appName
    type: string
stages:
  - stage: Build
    pool: { name: vmss-linux }       # the fleet you'll create in step 5
    jobs:
      - job: build
        steps:
          - script: echo "Building ${{ parameters.appName }}"
          - script: echo "MANDATORY scan step (consumer can't remove this)"
            displayName: 'Veracode SCA (mandatory)'

Step 4 — Make the app pipeline extend it. In the Shop-Web repo, add azure-pipelines.yml:

resources:
  repositories:
    - repository: templates
      type: git
      name: Templates/Templates
extends:
  template: ci/build.yml@templates
  parameters: { appName: shop-web }

Create the pipeline: az pipelines create --name shop-web-ci --repository Shop-Web --branch main --yml-path azure-pipelines.yml --project Shop-Web --skip-first-run. Expected: a pipeline that, when run, shows the mandatory scan step you defined in the template, not in the app YAML.

Step 5 — Create the VMSS and register it as an elastic pool.

# A small VMSS (start at 0 instances; ADO will scale it)
az vmss create -g rg-lab-agents -n vmss-lab \
  --image Ubuntu2204 --vm-sku Standard_B2s --instance-count 0 \
  --orchestration-mode Uniform --upgrade-policy-mode Manual \
  --disable-overprovision --load-balancer '""' --admin-username azureuser --generate-ssh-keys

az pipelines pool create --name vmss-linux \
  --vmss-resource-id $(az vmss show -g rg-lab-agents -n vmss-lab --query id -o tsv) \
  --vmss-os-type Linux --max-capacity 2 --desired-idle 0 --recycle-after-each-use true

Expected: an agent pool vmss-linux. With --desired-idle 0, no VM runs until a job queues; when you run the shop-web-ci pipeline, Azure DevOps spins up a VM, runs the one job, and tears it down.

Step 6 — Run it and watch the seam work.

az pipelines run --name shop-web-ci --project Shop-Web --open

Expected: the run queues, the VMSS scales 0→1, the agent picks up the job, the mandatory scan step from the template executes, and the VM is deleted afterward (ephemeral). You have now reproduced, in miniature, every load-bearing idea in this article.

Teardown.

az vmss delete -g rg-lab-agents -n vmss-lab
az pipelines pool delete --pool-id $(az pipelines pool list --query "[?name=='vmss-linux'].id|[0]" -o tsv) --yes
for p in IaC Templates Packages Shop-Web; do az devops project delete --id $(az devops project show --project "$p" --query id -o tsv) --yes; done

Common mistakes & troubleshooting

The failure modes below are the ones that actually bite when standing up or running this platform. Each is symptom → root cause → how to confirm → fix.

# Symptom Root cause Confirm (exact path/command) Fix
1 Deploys hang then fail i/o timeout to KV/DB Agent can’t reach the private endpoint (hosted agent, or VMSS not in hub) From the agent: nslookup kv.vault.azure.net returns public IP Put VMSS in hub subnet; link Private DNS Zone to the agent VNet
2 Jobs queue forever, never start Pool max capacity hit, or no parallel jobs bought, or image won’t boot Pool → Agents (all busy/offline); Org settings → Parallel jobs Raise max capacity / buy parallel jobs / fix the image
3 Build restores from nuget.org despite the feed Missing <clear/> — public source still inherited dotnet restore log shows nuget.org as a source Add <clear/> before the feed <add> in nuget.config
4 TLS failure to internal feed unable to get local issuer certificate Corporate root CA not in the golden image openssl s_client -connect feed:443 shows untrusted chain Add the CA to the image trust store; republish; roll VMSS
5 A template change breaks every pipeline at once Consumers float on the template’s default branch (no ref pin) Pipelines reference …@templates with no ref Pin ref: refs/tags/vX.Y.Z; roll the tag deliberately
6 terraform plan fails after a module change you didn’t make Root config pins a branch, not a tag, so it moved Module block has ?ref=main instead of ?ref=2.1 Pin to a semver tag; bump intentionally
7 Pipeline can deploy to Prod from a Non-Prod branch Service connection scoped too broadly (or shared) Service connection → resource scope is whole-tenant/MG Scope each connection to one subscription; separate Non-Prod/Prod
8 App team can edit the shared templates Templates repo write access too broad Templates project → Repos → Security shows app teams as Contributors Restrict to Reader for consumers; PR gate + required reviewers
9 Secrets visible in pipeline logs Variable not marked secret, or echoed Run log prints the value Mark variables secret / use Key Vault refs; never echo secrets
10 Agent runs out of disk mid-build Image disk too small for the workload (Docker layers, artifacts) Agent diag: No space left on device Larger OS disk / data disk in the image; prune in-job
11 First job after idle is slow (cold) Standby (desired idle) set to 0 Pool shows 0 idle agents; first job waits for boot Set desired idle ≥ 1 during business hours
12 Deploy denied: RequestDisallowedByPolicy A CAF Azure Policy guardrail blocks the resource Activity log → the deny with the policy name Comply with the guardrail (tags/region/PE), or request an exemption
13 VMSS scaling fights itself / odd instance counts VMSS native autoscale left enabled alongside ADO VMSS → Scaling shows autoscale rules Disable VMSS autoscale; let Azure DevOps manage the count
14 Cross-project pipeline can’t read another repo Project-scoped build identity lacks cross-project rights Run error: TF401019/access denied on the repo Grant the build identity read on that repo, or share the resource

The private-endpoint timeout (the single most common one)

Symptom 1 deserves detail because it is the failure that pushes teams to self-hosted agents in the first place, and it is easy to half-fix. The deploy task tries to read a Key Vault secret or connect to Azure MySQL and hangs for the connection timeout, then fails. The cause is almost always DNS: even with the VMSS in the hub and peering in place, if the Private DNS Zone (e.g. privatelink.vaultcore.azure.net) is not linked to the VNet the agent resolves from, the agent gets the public IP for the vault — which is firewalled off — and times out. Confirm by running nslookup kv-corp-prod.vault.azure.net from an agent (or a Bash/PowerShell step in a debug pipeline): a private IP (10.x) means DNS is correct; a public IP means the zone link is missing.

# In a debug pipeline step on the agent — prove what the name resolves to
nslookup kv-corp-prod.vault.azure.net
# private IP (10.x) → good;  public IP → Private DNS Zone not linked to the agent's VNet

The fix is to link the Private DNS Zone to the hub VNet (and ensure spoke zones are reachable), so the agent resolves the private IP. This same DNS-linkage trap is the root of most “it works from one VNet but not another” private-endpoint incidents, and is dissected in private endpoints and DNS at scale.

Best practices

Security notes

The platform’s security posture is mostly structural — it comes from the boundaries, not from bolt-ons. Least privilege by project: because a project is the RBAC boundary, a compromised app pipeline is contained to its project’s repos, feeds and service connections; it cannot reach the templates everyone runs or the modules that build the network. Workload-identity federation over secrets: service connections should use WIF (federated credentials, no stored client secret) so there is no long-lived secret to leak; the connection trades a short-lived token with Entra at run time. Ephemeral agents as a control: delete-after-each-use means a secret written to an agent’s disk, a poisoned cache, or a planted credential does not survive the job — the clean machine is a security guarantee, not just a hygiene nicety, and is the core argument of the scale-set agents hardening deep dive. Private everything: with the fleet in the hub and PaaS behind private endpoints, build/deploy traffic to Key Vault, databases, registry and storage never traverses the public internet. Key Vault per scope: non-prod credentials cannot read prod secrets because they are in different, privately-reachable vaults with separate access. Internal-feed enforcement as supply-chain defence: routing every package through one feed with upstream sources means typosquatting and dependency-confusion attacks hit a scannable, auditable choke point rather than reaching builds directly — pair it with DevSecOps SAST/DAST/SCA policy gates. RBAC discipline: use least-privilege custom roles for the pipeline identities and user-assigned managed identities where an identity must be shared across resources.

The identity-and-secret seams to lock down:

Seam Risk if loose Control
Service connection auth Stored secret leaks → tenant access Workload-identity federation (no secret)
Service connection scope Over-broad → cross-env deploys Scope to one subscription; split Non-Prod/Prod
Agent disk between jobs Secret/cache survives a job Ephemeral (delete after each use)
Templates write access Anyone alters every pipeline Platform-only write; PR gate; pin ref
Feed publish rights Rogue package in the supply chain Contributor limited; upstream + SCA scan
Key Vault access Non-prod reads prod secrets KV per scope; private endpoint; scoped access
Pipeline logs Secrets printed Mark secret; Key Vault refs; never echo

Cost & sizing

The platform’s bill has three real drivers: agent compute (the VMSS VMs while they run), parallel jobs (Azure DevOps concurrency you buy on top of the self-hosted free grant), and Azure Artifacts storage (feed size beyond the free tier). The headline saving of self-hosted scale-set agents is that they scale to zero: outside business hours the fleet runs the standby buffer only (often zero), so you pay for VMs when jobs actually run, not 24/7. Figures below are indicative (Central India, mid-2026) and round for planning.

Cost driver What it is Indicative price Lever to reduce it
VMSS agent compute Per-VM per-hour while running D4s_v5 ≈ ₹16–20/hr (~$0.19–0.24) Scale to zero off-hours; right-size SKU; spot for non-prod
Self-hosted parallel jobs Concurrency for self-hosted agents 1 free, then ≈ ₹1,250/mo (~$15) each Buy only the concurrency you actually queue
Microsoft-hosted parallel jobs If you keep some hosted 1 free (private), then ≈ ₹3,300/mo (~$40) each Prefer self-hosted at scale
Azure Artifacts storage Feed size 2 GiB free, then per-GiB/mo Retention policies; prune old versions
Compute Gallery image Golden image versions stored Storage of image versions Keep a few versions; delete old ones
Standby agents (idle) Warm VMs kept ready The VM hourly rate × idle count × hours Idle 0 off-hours; 1–2 in business hours

Sizing the fleet is a concurrency problem, not a guess. Estimate peak concurrent jobs (how many pipeline jobs run at the busiest minute), set max capacity a little above it, and set standby to cover the typical baseline so jobs start instantly. Each ephemeral agent runs one job, so max-capacity is roughly your peak-concurrent-jobs target.

If your peak concurrent jobs is… Set max capacity ≈ Standby (business hrs) Parallel jobs to buy
~5 8 1 ~5–8
~15 20 2 ~15–20
~30 35–40 2–3 ~30
Bursty (CI storms) Higher max, low standby 1–2 Cover the burst

A worked monthly estimate for a ~15-concurrent-job platform: a D4s_v5 fleet running, say, 200 VM-hours/month (jobs only, scaled to zero otherwise) ≈ ₹3,500–4,000 compute; ~15 self-hosted parallel jobs (minus the 1 free) ≈ ₹17,500/mo; Artifacts a few GiB over free ≈ a few hundred rupees. The dominant line is parallel jobs, not compute — which is the counterintuitive lesson: at scale you buy concurrency, and the VMs themselves, scaled to zero, are comparatively cheap. The broader cloud economics and TCO view applies if you are comparing against staying on hosted agents entirely.

Interview & exam questions

Q1. Why split an Azure DevOps organisation into separate IaC, templates, packages and application projects instead of one project? A project is the strongest isolation boundary short of a new org — its own repos, pipelines, feeds, service connections and RBAC. Splitting by responsibility scopes permissions (a module change doesn’t grant package-publish or app-deploy rights) and contains blast radius (a broken app pipeline can’t break the templates everyone runs). It also lets each concern have its own lifecycle and approvals.

Q2. Why one Git repo per Terraform module rather than a monorepo? Repo-per-module gives independent semantic versioning (each module tags its own 2.1), isolated state and blast radius, granular per-module RBAC, and focused PRs reviewed by that module’s owners. A consumer pins ?ref=2.1 and is immune to upstream changes until it bumps. A monorepo trades all of that for a single clone.

Q3. What is the difference between extends and template in Azure Pipelines, and when do you use each? template is opt-in reuse — a pipeline includes a stage/job/steps it chooses. extends is governance — the consumer declares it extends a platform template, which owns the overall shape and can inject mandatory steps the consumer cannot remove. Use extends to enforce security scans and feed enforcement org-wide; use template for DRY fragments.

Q4. How does internal-feed enforcement work and why does it matter? You make the build resolve packages only through one Azure Artifacts feed, with public registries reached solely via the feed’s upstream sources. In nuget.config this means <clear/> to drop the public source, leaving only the feed. It matters for provenance (one origin for every binary), resilience (cached copies survive upstream outages), and security (a scannable, auditable choke point against typosquatting and dependency confusion).

Q5. When do you choose self-hosted scale-set agents over Microsoft-hosted, and when not? Choose scale-set agents for private-endpoint reach (the decisive one once PaaS goes private), custom images, larger SKUs/longer jobs, warm caches, and cost at high concurrency. Stay on hosted when you’re a small team that values zero maintenance and doesn’t need private networking — the fleet’s overhead isn’t worth it below roughly three teams.

Q6. Who manages the scaling of an agent-pool VMSS — you or Azure DevOps? Azure DevOps. You register the VMSS as an elastic pool and give it a maximum agent count and a standby (idle) count; Azure DevOps watches the job queue and adds/removes VMs. You should disable the VMSS’s own autoscale rules so they don’t fight Azure DevOps’s scaling.

Q7. Why place the agent fleet in the hub of a hub-spoke network? Because the hub peers to every spoke and is linked to the Private DNS Zones, an agent in the hub can reach private endpoints (Key Vault, Azure SQL/MySQL/PostgreSQL, ACR, Storage) and resolve their names to private IPs. A Microsoft-hosted agent on the public network resolves those names to disabled public endpoints and times out.

Q8. What does “ephemeral / delete after each use” buy you, and what does it cost? It guarantees a clean machine per job: no secret, cache or credential survives across jobs, eliminating drift and a class of leaks — a security control. The cost is no cross-job cache warmth (you get warmth from the image instead) and slightly higher compute from recreating VMs.

Q9. What is a CAF landing zone and how does it relate to where pipelines deploy? It’s the governed target: a management-group hierarchy (Tenant Root → org → Landing Zone → Corporate → Corp Non-Production / Corp Production) with Azure Policy guardrails cascading down, a Key Vault per scope, and a hub-spoke network with private endpoints/DNS/peering. A pipeline’s service connection is scoped to a subscription under one group, and Policy denies a non-compliant deploy before any resource is created.

Q10. Why a Key Vault per landing-zone scope instead of one shared vault? So a non-prod pipeline credential can never read a production secret — the vaults are separate, privately reachable, and access is granted only to that scope’s identities. It applies the same blast-radius logic as the project boundaries, to secrets.

Q11. A deploy fails with i/o timeout to Key Vault from a self-hosted agent in the hub. What’s the most likely cause and how do you confirm it? The Private DNS Zone (privatelink.vaultcore.azure.net) isn’t linked to the VNet the agent resolves from, so the agent gets the vault’s public IP (firewalled) instead of the private one. Confirm with nslookup kv….vault.azure.net on the agent: a public IP means the zone link is missing; link it to the agent’s VNet.

Q12. Which Azure certifications does this map to? AZ-400 (Designing and Implementing Microsoft DevOps Solutions) for the pipeline/agent/Artifacts design; AZ-104/AZ-305 for the landing-zone, networking and identity pieces; and Terraform Associate for the module/state discipline. The platform-engineering framing also aligns with the practices in the DORA/platform-engineering body of knowledge.

Quick check

  1. Name the four single-responsibility projects in this organisation design and one reason each is its own project.
  2. Why must a root config pin a Terraform module to a tag (?ref=2.1) rather than a branch?
  3. Which template mechanism — extends or template — lets the platform inject a mandatory security step a consumer cannot remove?
  4. What single line in nuget.config enforces that the build restores only through the internal feed?
  5. Why can a Microsoft-hosted agent not deploy to a Key Vault that has its public endpoint disabled, and what fixes it?

Answers

  1. IaC (isolate infra blast radius and module RBAC), pipeline-templates (one repo reaches every pipeline — restrict who edits it), packages (single governed feed; package permissions independent of app RBAC), application (teams own code/deploys without touching shared platform assets).
  2. A tag is immutable, so the consumer is immune to upstream changes until it deliberately bumps; a branch moves, so an unrelated module change can break your plan without you changing anything.
  3. extends — the consumer extends the platform template, which owns the shape and injects unremovable steps; template is opt-in reuse the consumer assembles.
  4. <clear/> — it removes any inherited public source (e.g. nuget.org), leaving only the feed, so public packages can arrive only via the feed’s upstream.
  5. The hosted agent is on Microsoft’s public network and resolves the vault name to its (disabled) public IP, so it times out; a self-hosted scale-set agent in the hub, with the Private DNS Zone linked, resolves the private IP and reaches it over peering.

Glossary

Next steps

Azure DevOpsPlatform EngineeringVMSS AgentsTerraformAzure ArtifactsLanding ZoneCI/CDHub-Spoke
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