- Published on
Why I Centralized Azure DevOps Pipeline Logic
- Authors

- Name
- Ryan Todd
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 own | The platform repo owns |
|---|---|
| Environment variables | Build logic |
| Service-specific settings | Security scanning |
| Deployment parameters | Artifact handling |
| Which version of the platform to ride | Deployment 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."