Background
Multiple development teams, each maintaining their own deployment process. Some had proper pipelines. Some had basic ones. A few were still deploying by connecting directly to servers via RDP and running scripts manually.
The organization had Azure DevOps. That wasn't the problem. The problem was that every team had built their pipelines differently, with different approval requirements and no shared standard. When deployments failed — and they did, regularly — root cause analysis was hard because the deployment process itself was inconsistent and often undocumented.
Anil Choudhary led the standardization program from discovery through template design, rollout, and team adoption.
The Challenge
Inconsistent Pipeline Implementations
The audit across teams revealed a wide spectrum:
| Team | Deployment Method | Pipeline State | Rollback Capability |
|---|---|---|---|
| Team A | Manual RDP + script | No pipeline | Manual re-deploy |
| Team B | Basic Azure DevOps classic | Single stage, no gates | None |
| Team C | YAML pipeline | No environment promotion | Re-deploy previous artifact |
| Team D | Azure DevOps classic | Multi-stage, but no artifact versioning | None automated |
| Team E | Script-based CI only | No CD | Manual |
No two teams followed the same approach. A deployment engineer moving between teams had to learn a new process each time. When incidents occurred, the diversity of approaches made cross-team support impossible.
Deployment-Caused Incidents
A significant portion of production incidents were traced back to deployment issues:
- Manual deployment steps applied in the wrong order
- Configuration values from the wrong environment copied to production
- No automated rollback — recovering from a bad deployment required engineers to manually revert files or re-run scripts from memory
- No artifact versioning — there was no reliable way to know exactly what version of code was running in production
Environment Segregation Gaps
Most teams deployed to production using the same process as development:
- No mandatory environment promotion gate (Dev → Test → Production)
- Pre-production testing was optional and inconsistently applied
- No separation of deployment credentials between environments — the same service principal that deployed to dev could deploy to production
- Security scanning was absent from all pipelines
Approval and Governance
Production deployments had no standardized approval process:
- Some teams required verbal approval from a tech lead before deploying
- Others deployed directly with no approval gate
- There was no audit trail of who approved what deployment and when
- Rollbacks required a manager to be contacted, delaying recovery time
Solution: Shared Pipeline Template Library
The core of the solution was a centralized Azure DevOps repository containing reusable YAML pipeline templates that any team could reference. Rather than each team building their own pipeline, they extend the standard — customizing only what is application-specific.
Repository Structure
azure-pipelines-templates/
├── stages/
│ ├── build.yml # Language-agnostic build stage
│ ├── security-scan.yml # SAST + dependency scanning
│ ├── deploy-env.yml # Parameterized environment deployment
│ └── rollback.yml # Automated rollback trigger
├── steps/
│ ├── dotnet-build.yml # .NET-specific build steps
│ ├── node-build.yml # Node.js build steps
│ ├── publish-artifact.yml
│ └── notify-teams.yml # Teams notification on success/failure
├── variables/
│ ├── dev.yml
│ ├── test.yml
│ └── prod.yml
└── README.md
Core Pipeline: Multi-Stage Promotion
The standard pipeline template enforces promotion through environments in sequence. Non-production stages run automatically; production requires explicit approval:
# templates/stages/deploy-env.yml
parameters:
- name: environment
type: string
- name: serviceConnection
type: string
- name: artifactName
type: string
stages:
- stage: Deploy_${{ parameters.environment }}
displayName: "Deploy to ${{ parameters.environment }}"
${{ if eq(parameters.environment, 'production') }}:
jobs:
- deployment: Deploy
environment: production # Requires approval gate configured in Azure DevOps Environments
strategy:
runOnce:
deploy:
steps:
- template: ../steps/deploy-app.yml
parameters:
serviceConnection: ${{ parameters.serviceConnection }}
artifactName: ${{ parameters.artifactName }}
${{ if ne(parameters.environment, 'production') }}:
jobs:
- job: Deploy
steps:
- template: ../steps/deploy-app.yml
parameters:
serviceConnection: ${{ parameters.serviceConnection }}
artifactName: ${{ parameters.artifactName }}
Artifact Versioning
Every successful build produces a versioned artifact stored in Azure Artifacts:
# templates/steps/publish-artifact.yml
steps:
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: '$(Build.ArtifactStagingDirectory)'
artifactName: '${{ parameters.appName }}-$(Build.BuildNumber)'
publishLocation: 'Container'
- script: |
echo "Build: $(Build.BuildNumber)"
echo "Commit: $(Build.SourceVersion)"
echo "Branch: $(Build.SourceBranchName)"
echo "Triggered by: $(Build.RequestedFor)"
displayName: 'Log build provenance'
The build number format was standardized as YYYY.MM.DD.BuildCounter across all applications, giving a consistent, parseable version string that could be used to identify exactly what was running in any environment.
Automated Rollback
The rollback template enabled one-click recovery to any previous artifact version:
# templates/stages/rollback.yml
parameters:
- name: rollbackBuildId
type: string
default: ''
stages:
- stage: Rollback
displayName: "Rollback Deployment"
condition: failed() # Automatic trigger on deployment failure
jobs:
- deployment: RollbackDeploy
environment: ${{ parameters.environment }}
strategy:
runOnce:
deploy:
steps:
- task: DownloadBuildArtifacts@1
inputs:
buildType: 'specific'
buildId: ${{ parameters.rollbackBuildId }}
- template: ../steps/deploy-app.yml
parameters:
isRollback: true
When a deployment stage failed, the rollback stage triggered automatically — deploying the previously known-good artifact without manual intervention.
Security Scanning Integration
Security scanning was added as a mandatory gate before any deployment proceeds:
# templates/stages/security-scan.yml
stages:
- stage: SecurityScan
displayName: "Security Scanning"
jobs:
- job: DependencyCheck
steps:
- task: dependency-check-build-task@6
inputs:
projectName: '$(Build.Repository.Name)'
scanPath: '$(Build.SourcesDirectory)'
format: 'JUNIT'
failOnCVSS: '7' # Fail build on High or Critical CVE
- job: SAST
steps:
- task: MicrosoftSecurityDevOps@1
inputs:
categories: 'secrets,code'
Builds with high or critical CVEs in dependencies were blocked from deploying to any environment. Security engineers received automatic notifications with findings.
Environment-Scoped Service Principals
Separate Azure AD service principals were created per environment, each scoped to minimum required permissions:
| Service Principal | Scope | Permissions |
|---|---|---|
| sp-deploy-dev | Dev resource group | Contributor |
| sp-deploy-test | Test resource group | Contributor |
| sp-deploy-prod | Prod resource group | Contributor — requires approval gate to use |
No pipeline could deploy to production without triggering the approval gate that used the production-scoped service principal. The development service principal had no permissions on production resources — even a misconfigured pipeline couldn't accidentally deploy to production.
Teams adoption
A standard application pipeline file that new applications referenced looked like this:
# azure-pipelines.yml (in each application repo)
trigger:
branches:
include: [main, release/*]
resources:
repositories:
- repository: templates
type: git
name: DevOps/azure-pipelines-templates
ref: refs/heads/main
variables:
- template: variables/dev.yml@templates
- name: appName
value: 'my-application'
stages:
- template: stages/build.yml@templates
parameters:
appName: $(appName)
- template: stages/security-scan.yml@templates
- template: stages/deploy-env.yml@templates
parameters:
environment: development
serviceConnection: 'sc-deploy-dev'
artifactName: $(appName)
- template: stages/deploy-env.yml@templates
parameters:
environment: test
serviceConnection: 'sc-deploy-test'
artifactName: $(appName)
- template: stages/deploy-env.yml@templates
parameters:
environment: production
serviceConnection: 'sc-deploy-prod'
artifactName: $(appName)
Each team's application pipeline was reduced to ~40 lines of YAML. All the complexity lived in the shared templates.
Results
| Dimension | Before | After |
|---|---|---|
| Average deployment time | 45–90 minutes | 20–40 minutes (50% reduction) |
| Manual deployment steps | Present in all teams | Eliminated — fully automated |
| Rollback time | 30–120 minutes (manual) | Under 5 minutes (automated) |
| Security scanning coverage | 0% of applications | 100% — mandatory gate |
| Environment promotion gates | Inconsistent / none | Standardized — approval required for production |
| Artifact versioning | No standard | Consistent across all applications |
| Deployment audit trail | None | Full history in Azure DevOps with approver identity |
| Deployment-caused incidents | Regular | Eliminated post-standardization |
| New application pipeline setup | Days per application | Under 1 hour (copy template + configure) |
The shared template approach also changed the onboarding story entirely. Instead of each team spending days building their own pipeline from scratch, a new application was deployable through the full pipeline within an hour of adopting the template. Less time building infrastructure, more time shipping product.
