Published on

Why I Centralized Azure DevOps Pipeline Logic

Authors
  • avatar
    Name
    Ryan Todd
    Twitter

One of the first things I learned building CI/CD at scale is that pipeline maintenance becomes a problem long before most teams expect.

The pattern that feels right

A common pattern is giving every repository its own complete pipeline definition. At first that seems reasonable. Each application owns its pipeline, teams change things independently, and everything feels self-contained.

The problem appears when you're the person responsible for maintaining all of them.

Every time I wanted to improve the deployment process — upgrade a task version, add a security scan, change deployment behavior, fix a bug — I found myself making the same change across multiple repositories. The applications were different. The pipeline logic was not.

I was spending my time maintaining copies of the same release process.

Treating CI/CD as a platform

Instead of treating every repository as a special case, I started treating CI/CD as a platform. I created a centralized pipeline repository that owned the deployment logic and used Azure DevOps extends templates to keep application repositories thin.

The service repositories still contained a pipeline file — but that file became configuration rather than implementation.

Application repos ownThe platform repo owns
Environment variablesBuild logic
Service-specific settingsSecurity scanning
Deployment parametersArtifact handling
Which version of the platform to rideDeployment workflows
Shared release engineering standards

What that actually looks like

Here's what a service's deploy pipeline used to be. Every stage, every dependency, every variable wired by hand — and repeated in every repo:

stages:
- stage: Build
  jobs:
  - template: templates/Java_Build/template.yml@pipeline-scripts
    parameters:
      VERSION: ${{ variables.VERSION }}
- stage: Deploy
  dependsOn: Build
  jobs:
  - template: templates/WebApp_deploy/template.yml@pipeline-scripts
    parameters:
      AZURE_SUBSCRIPTION: ${{ variables.AZURE_SUBSCRIPTION }}
      WEBAPP_NAME:        ${{ variables.WEBAPP_NAME }}
      RESOURCE_GROUP:     ${{ variables.RESOURCE_GROUP }}
      ARTIFACT_NAME:      ${{ variables.ARTIFACT_NAME }}
      # ...and so on, in every repo
- stage: SonarQube
  dependsOn: Build
  # ...

If I wanted to add a step to the deploy stage, I changed it here — and then in every other repo that had copied this shape.

Here is the same pipeline after the logic moved into the platform repo:

extends:
  template: templates-v2/webapp/deploy/java/PR_merge.yml@pipeline-scripts
  parameters:
    VARIABLES_FILE: .azuredevops/variables/dev.yml

The stages didn't disappear. They moved. The service repo stopped describing how to build and deploy and started describing only what it is — its variables, its environments. The release process now lives in exactly one place.

New capabilities arrive by default

The biggest benefit wasn't fewer YAML files. It was reducing the number of places I needed to make a change. When I improved the deployment process, I updated one repository. When I fixed a pipeline bug, I fixed it once.

The clearest example: I wanted SonarQube scanning on every service, but I didn't want it slowing anyone's deploy down. So I built it as an async dispatch. When a service merges a PR, its pipeline fires a one-line call and moves on:

- template: templates-v2/dispatch/sonar.yml@pipeline-scripts
  parameters:
    SONAR_PIPELINE_ID: <dispatch-pipeline-id>

That single block mints a token, hands the repo and commit to a central scan pipeline, and returns the moment the request is accepted. The scan runs on its own and never blocks the team's build. For this use case, I treated scan dispatch failures as non-blocking and surfaced them as warnings. That kept deploys moving while still giving me one central place to improve scan reliability.

The point isn't the scan. It's that I added a brand-new capability — token auth, cross-project cloning, certificate handling, the scan itself — without opening a single service repository. Any service already using the platform pipeline picked it up without a repo-level migration.

That's the difference between maintaining a platform and maintaining copies: new capabilities arrive by default instead of by migration.

The trade-off I accepted

Centralizing the logic moves the risk, it doesn't delete it. When every repo extends the same templates, one bad change can break every pipeline at once. That blast radius is the cost of the model, and it's worth being honest about.

The control is versioning. Every consuming repo pins which version of the platform it tracks:

resources:
  repositories:
    - repository: pipeline-scripts
      type: git
      name: Shared/pipeline-scripts
      ref: refs/heads/main      # ← the version this repo rides

Pointing at main means a repo always gets the latest logic — convenient, but it trusts every change. When I'm reworking a template, I push to a branch and point one repo's ref at that branch to prove the change out before it reaches everyone. The same indirection that creates the blast radius is what lets me contain it.

The other cost is autonomy: teams can no longer change deployment behavior by editing their own file. That's a real trade. I decided consistency across the platform was worth more than per-repo flexibility — but that's a judgment call, not a free win.

What I'd tell past me

If you're maintaining dozens of pipelines and making the same change repeatedly, you're probably maintaining copies instead of maintaining a platform.

Treat your delivery process like a product. Centralize what should be shared, leave application repositories focused on application-specific configuration — and pin your versions so "shared" never means "everything breaks at once."