Back to Software Engineering & Delivery Mastery Series Azure DevOps Bootcamp

Module 6: Templates & Reusability

June 3, 2026 Wasil Zafar 40 min read

Master Azure Pipelines templates for DRY pipeline code — step templates, job templates, stage templates, variable templates, extends templates, template expressions, parameterization, shared template repositories, and enterprise governance through required templates.

Table of Contents

  1. Template Types
  2. Advanced Patterns
  3. Governance
  4. Practice

Why Templates Matter

By Module 5, you can build CI pipelines for any language — .NET, Node.js, Python, containers. But here's a problem that emerges in real organizations: you're copy-pasting the same YAML blocks across 50+ repositories. When a security team mandates a new scanning step, you touch 50 files. When a build optimization is discovered, you propagate it manually. This is the DRY problem (Don't Repeat Yourself) applied to CI/CD.

Templates are to pipelines what functions are to code — they encapsulate reusable logic, accept parameters, and can be versioned independently. An extends template is like an abstract base class: it defines the structure, and consuming pipelines fill in the details.

The Five Template Types

Template Type What It Encapsulates Use Case
Step template A sequence of steps Reusable build/test/deploy steps
Job template Complete job with pool/strategy Standardized build or validation jobs
Stage template Full stage with jobs Deployment stages with approvals
Variable template Variable definitions Environment-specific configuration
Extends template Entire pipeline skeleton Enterprise governance & enforcement

When to Extract a Template

Not everything should be a template. Extract when:

  • 3+ pipelines share the same logic (the "rule of three")
  • A security or compliance requirement must be enforced everywhere
  • Teams want golden paths — opinionated defaults with escape hatches
  • You need versioned, auditable infrastructure-as-code patterns
Mental model: Templates sit on a spectrum from "convenience" (step templates for DRY) to "governance" (extends templates for enforcement). Start simple — extract step templates first. Graduate to extends templates when you need organizational control.

Step Templates

Step templates are the simplest form — a file containing a reusable sequence of steps. Think of them as macros: you define steps once, then insert them into any job.

Creating a Step Template

A step template file contains a parameters section (inputs) and a steps section (what to execute). It lives in your repo or a shared template repo.

# File: templates/steps/dotnet-build.yml
# Step template: Build and test a .NET application

parameters:
  - name: dotnetVersion
    type: string
    default: '8.0.x'
  - name: projectPath
    type: string
    default: '**/*.csproj'
  - name: configuration
    type: string
    default: 'Release'
  - name: runTests
    type: boolean
    default: true

steps:
  # Install the specified .NET SDK version
  - task: UseDotNet@2
    displayName: 'Install .NET SDK ${{ parameters.dotnetVersion }}'
    inputs:
      packageType: 'sdk'
      version: '${{ parameters.dotnetVersion }}'

  # Restore NuGet packages
  - script: dotnet restore ${{ parameters.projectPath }}
    displayName: 'Restore packages'

  # Build the project
  - script: dotnet build ${{ parameters.projectPath }} --configuration ${{ parameters.configuration }} --no-restore
    displayName: 'Build (${{ parameters.configuration }})'

  # Conditionally run tests
  - ${{ if eq(parameters.runTests, true) }}:
    - script: dotnet test ${{ parameters.projectPath }} --configuration ${{ parameters.configuration }} --no-build --logger trx
      displayName: 'Run tests'
    - task: PublishTestResults@2
      displayName: 'Publish test results'
      inputs:
        testResultsFormat: 'VSTest'
        testResultsFiles: '**/*.trx'

Consuming a Step Template

The consuming pipeline references the template with a relative path and passes parameters:

# File: azure-pipelines.yml
# Pipeline that uses the step template

trigger:
  branches:
    include: [main]

pool:
  vmImage: 'ubuntu-latest'

steps:
  # Insert the build template with custom parameters
  - template: templates/steps/dotnet-build.yml
    parameters:
      dotnetVersion: '8.0.x'
      projectPath: 'src/MyApp/MyApp.csproj'
      configuration: 'Release'
      runTests: true

  # Additional steps specific to this pipeline
  - script: echo "Build complete — custom post-processing here"
    displayName: 'Post-build step'

The template: keyword injects all steps from the template file at that point in the pipeline. Parameters flow in, and the template's steps execute as if they were written inline.

Key insight: Step templates can also be used inside job templates and stage templates. They compose — a stage template can include a job template which includes a step template. This layered approach keeps each file focused.

Job Templates

When you need to standardize not just the steps but also the agent pool, strategy, and job-level settings, use a job template. This is common for matrix builds, build + publish patterns, and validation gates.

Job Template for Build + Publish

# File: templates/jobs/build-publish.yml
# Job template: Build an application and publish artifacts

parameters:
  - name: buildName
    type: string
  - name: pool
    type: string
    default: 'ubuntu-latest'
  - name: dotnetVersion
    type: string
    default: '8.0.x'
  - name: projectPath
    type: string
  - name: artifactName
    type: string
    default: 'drop'

jobs:
  - job: Build_${{ replace(parameters.buildName, ' ', '_') }}
    displayName: 'Build: ${{ parameters.buildName }}'
    pool:
      vmImage: '${{ parameters.pool }}'
    steps:
      - task: UseDotNet@2
        displayName: 'Install .NET SDK'
        inputs:
          packageType: 'sdk'
          version: '${{ parameters.dotnetVersion }}'

      - script: |
          dotnet restore ${{ parameters.projectPath }}
          dotnet build ${{ parameters.projectPath }} --configuration Release --no-restore
          dotnet publish ${{ parameters.projectPath }} --configuration Release --no-build --output $(Build.ArtifactStagingDirectory)
        displayName: 'Restore, Build, Publish'

      - task: PublishBuildArtifacts@1
        displayName: 'Publish artifact: ${{ parameters.artifactName }}'
        inputs:
          PathtoPublish: '$(Build.ArtifactStagingDirectory)'
          ArtifactName: '${{ parameters.artifactName }}'

Consuming the Job Template

# File: azure-pipelines.yml
# Pipeline consuming a job template

trigger:
  branches:
    include: [main, develop]

# Use the job template for both API and Worker service
jobs:
  - template: templates/jobs/build-publish.yml
    parameters:
      buildName: 'API Service'
      projectPath: 'src/Api/Api.csproj'
      artifactName: 'api-drop'

  - template: templates/jobs/build-publish.yml
    parameters:
      buildName: 'Worker Service'
      projectPath: 'src/Worker/Worker.csproj'
      artifactName: 'worker-drop'
      pool: 'windows-latest'  # Override default pool

Notice how the same template generates two independent jobs with different parameters. Each runs in parallel on its own agent. This pattern scales — add 10 services by adding 10 template references.

Stage Templates

Stage templates encapsulate complete deployment stages — including environment references, approval gates, and deployment strategies. This is where templates become genuinely powerful for multi-environment pipelines.

# File: templates/stages/deploy.yml
# Stage template: Deploy to an environment with approvals

parameters:
  - name: environment
    type: string
  - name: artifactName
    type: string
    default: 'drop'
  - name: appServiceName
    type: string
  - name: resourceGroup
    type: string
  - name: dependsOn
    type: object
    default: []
  - name: condition
    type: string
    default: 'succeeded()'

stages:
  - stage: Deploy_${{ parameters.environment }}
    displayName: 'Deploy to ${{ parameters.environment }}'
    dependsOn: ${{ parameters.dependsOn }}
    condition: ${{ parameters.condition }}
    jobs:
      - deployment: deploy
        displayName: 'Deploy to ${{ parameters.environment }}'
        pool:
          vmImage: 'ubuntu-latest'
        environment: '${{ parameters.environment }}'
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: '${{ parameters.artifactName }}'

                - task: AzureWebApp@1
                  displayName: 'Deploy to App Service'
                  inputs:
                    azureSubscription: 'Azure-ServiceConnection'
                    appType: 'webApp'
                    appName: '${{ parameters.appServiceName }}'
                    resourceGroupName: '${{ parameters.resourceGroup }}'
                    package: '$(Pipeline.Workspace)/${{ parameters.artifactName }}/**/*.zip'

Composing a Multi-Stage Pipeline

# File: azure-pipelines.yml
# Multi-stage pipeline assembled from templates

trigger:
  branches:
    include: [main]

stages:
  # Build stage (inline — or could also be a template)
  - stage: Build
    jobs:
      - template: templates/jobs/build-publish.yml
        parameters:
          buildName: 'WebApp'
          projectPath: 'src/WebApp/WebApp.csproj'

  # Deploy to Dev (automatic after build)
  - template: templates/stages/deploy.yml
    parameters:
      environment: 'dev'
      appServiceName: 'myapp-dev'
      resourceGroup: 'rg-myapp-dev'
      dependsOn: ['Build']

  # Deploy to Staging (automatic after dev)
  - template: templates/stages/deploy.yml
    parameters:
      environment: 'staging'
      appServiceName: 'myapp-staging'
      resourceGroup: 'rg-myapp-staging'
      dependsOn: ['Deploy_dev']

  # Deploy to Production (manual approval via environment)
  - template: templates/stages/deploy.yml
    parameters:
      environment: 'production'
      appServiceName: 'myapp-prod'
      resourceGroup: 'rg-myapp-prod'
      dependsOn: ['Deploy_staging']

Three environments, same deployment logic, zero duplication. Approvals are configured on the environment resource in Azure DevOps (not in the YAML), so the template stays generic.

Variable Templates

Variable templates externalize configuration into separate YAML files. This separates what to do (pipeline logic) from what values to use (configuration), making it trivial to have per-environment settings.

# File: templates/variables/dev.yml
# Variable template: Development environment settings

variables:
  environment: 'dev'
  azureSubscription: 'Azure-Dev-ServiceConnection'
  resourceGroup: 'rg-myapp-dev'
  appServiceName: 'myapp-dev'
  appInsightsKey: '$(DEV_APPINSIGHTS_KEY)'  # from pipeline secrets
  minReplicas: 1
  maxReplicas: 3
# File: templates/variables/production.yml
# Variable template: Production environment settings

variables:
  environment: 'production'
  azureSubscription: 'Azure-Prod-ServiceConnection'
  resourceGroup: 'rg-myapp-prod'
  appServiceName: 'myapp-prod'
  appInsightsKey: '$(PROD_APPINSIGHTS_KEY)'
  minReplicas: 3
  maxReplicas: 20

Using Variable Templates in a Pipeline

# File: azure-pipelines.yml
# Pipeline that loads variables from a template

trigger:
  branches:
    include: [main]

variables:
  # Load shared variables for this environment
  - template: templates/variables/dev.yml
  # You can also combine with inline variables
  - name: buildConfiguration
    value: 'Release'

pool:
  vmImage: 'ubuntu-latest'

steps:
  - script: echo "Deploying to $(environment) — App Service: $(appServiceName)"
    displayName: 'Show configuration'

  - script: echo "Replicas: min=$(minReplicas), max=$(maxReplicas)"
    displayName: 'Show scaling config'

Variable templates are especially useful when combined with parameters — a pipeline can choose which variable file to load based on a parameter value.

Extends Templates (Enterprise Governance)

This is the most powerful — and most important — template type. An extends template defines the entire pipeline skeleton. Consuming pipelines don't write their own structure; they fill in hooks provided by the template. The template "wraps" the pipeline.

Why Extends Templates Exist

Imagine you're a platform engineering team responsible for 200 developers across 80 repositories. You need every pipeline to:

  1. Run credential scanning (detect leaked secrets)
  2. Run SAST (static application security testing)
  3. Generate an SBOM (software bill of materials)
  4. Only deploy to approved environments

With step/job templates, teams can forget to include them. With extends + required template policies, teams cannot skip them.

Creating an Extends Template

# File: templates/pipeline-skeleton.yml (in central shared repo)
# Extends template: Enforces security scanning on every pipeline

parameters:
  - name: buildSteps
    type: stepList
    default: []
  - name: testSteps
    type: stepList
    default: []
  - name: pool
    type: string
    default: 'ubuntu-latest'

stages:
  - stage: Build
    displayName: 'Build & Security Scan'
    jobs:
      - job: BuildAndScan
        displayName: 'Build, Test & Security Scan'
        pool:
          vmImage: '${{ parameters.pool }}'
        steps:
          # === MANDATORY: Credential scanning (teams CANNOT remove this) ===
          - task: CredScan@3
            displayName: ' Credential Scan (enforced)'
            inputs:
              toolMajorVersion: 'V2'

          # === TEAM-PROVIDED: Build steps injected here ===
          - ${{ each step in parameters.buildSteps }}:
            - ${{ step }}

          # === TEAM-PROVIDED: Test steps injected here ===
          - ${{ each step in parameters.testSteps }}:
            - ${{ step }}

          # === MANDATORY: SAST scanning (teams CANNOT remove this) ===
          - task: RoslynAnalyzers@3
            displayName: ' SAST Analysis (enforced)'

          # === MANDATORY: SBOM generation ===
          - task: ManifestGeneratorTask@0
            displayName: ' Generate SBOM (enforced)'
            inputs:
              BuildDropPath: '$(Build.ArtifactStagingDirectory)'

Consuming an Extends Template

# File: azure-pipelines.yml (in team's repo)
# This pipeline EXTENDS the central template — it cannot remove mandatory steps

trigger:
  branches:
    include: [main]

resources:
  repositories:
    - repository: templates
      type: git
      name: Platform/pipeline-templates
      ref: refs/tags/v2.1.0

extends:
  template: templates/pipeline-skeleton.yml@templates
  parameters:
    pool: 'ubuntu-latest'
    buildSteps:
      - script: dotnet restore src/MyApp.csproj
        displayName: 'Restore'
      - script: dotnet build src/MyApp.csproj --configuration Release
        displayName: 'Build'
      - script: dotnet publish src/MyApp.csproj -o $(Build.ArtifactStagingDirectory)
        displayName: 'Publish'
    testSteps:
      - script: dotnet test tests/MyApp.Tests.csproj --logger trx
        displayName: 'Unit Tests'
Security enforcement: With extends templates + the "Required YAML templates" policy, platform teams can enforce that every pipeline runs security scanning — individual teams cannot skip it. The template wraps their code with mandatory steps before and after.

How Extends Wraps the Pipeline

Extends Template Execution Model
flowchart TD
    A["Team's azure-pipelines.yml"] -->|extends| B[Central Template]
    B --> C["Credential Scan (mandatory)"]
    C --> D["Team's Build Steps (injected)"]
    D --> E["Team's Test Steps (injected)"]
    E --> F["SAST Analysis (mandatory)"]
    F --> G["SBOM Generation (mandatory)"]
                            

The team controls what they build and test. The platform team controls the security envelope around it. Neither can override the other.

Advanced Template Parameters

Template parameters go far beyond simple strings. Azure Pipelines supports rich parameter types that enable complex, type-safe template configurations.

Parameter Types

Type Description Example Value
string Free-form text 'ubuntu-latest'
number Integer value 3
boolean True/false true
object YAML mapping (key-value pairs) { key: value }
stepList List of pipeline steps Array of step definitions
jobList List of pipeline jobs Array of job definitions
stageList List of pipeline stages Array of stage definitions

Constrained Parameters with Allowed Values

# Template with constrained and validated parameters
parameters:
  - name: environment
    type: string
    values:              # Only these values are allowed
      - dev
      - staging
      - production

  - name: deployRegions
    type: object         # Complex object parameter
    default:
      primary: 'eastus'
      secondary: 'westeurope'
      enableFailover: true

  - name: extraSteps
    type: stepList       # Teams can inject custom steps
    default: []

Conditional Logic with Parameters

# File: templates/steps/deploy-with-conditionals.yml
# Template demonstrating conditional logic based on parameters

parameters:
  - name: environment
    type: string
  - name: runSmoke
    type: boolean
    default: true

steps:
  - script: echo "Deploying to ${{ parameters.environment }}"
    displayName: 'Deploy'

  # Only run smoke tests in non-production
  - ${{ if and(eq(parameters.runSmoke, true), ne(parameters.environment, 'production')) }}:
    - script: npm run test:smoke
      displayName: 'Smoke Tests'

  # Production gets a canary deployment step
  - ${{ if eq(parameters.environment, 'production') }}:
    - script: |
        echo "Canary deployment — routing 10% traffic to new version"
        az webapp traffic-routing set --name myapp --resource-group rg-prod \
          --distribution staging=10
      displayName: 'Canary: 10% traffic routing'

  # Always run health check
  - script: curl --fail https://myapp-${{ parameters.environment }}.azurewebsites.net/health
    displayName: 'Health Check'

Template expressions (${{ }}) are evaluated at compile time — before the pipeline runs. This means conditional steps are included or excluded from the plan before any agent picks up the job.

Template Expressions & Iteration

Template expressions let you generate pipeline YAML dynamically — looping over lists, conditionally inserting blocks, and transforming data. This is where templates become a genuine metaprogramming layer over your pipelines.

Iterating with ${{ each }}

# File: templates/stages/multi-env-deploy.yml
# Template that generates a deploy stage for EACH environment in a list

parameters:
  - name: environments
    type: object
    default:
      - name: dev
        appService: myapp-dev
        resourceGroup: rg-dev
        dependsOn: Build
      - name: staging
        appService: myapp-staging
        resourceGroup: rg-staging
        dependsOn: Deploy_dev
      - name: production
        appService: myapp-prod
        resourceGroup: rg-prod
        dependsOn: Deploy_staging

stages:
  - ${{ each env in parameters.environments }}:
    - stage: Deploy_${{ env.name }}
      displayName: 'Deploy → ${{ env.name }}'
      dependsOn: ${{ env.dependsOn }}
      jobs:
        - deployment: deploy
          pool:
            vmImage: 'ubuntu-latest'
          environment: '${{ env.name }}'
          strategy:
            runOnce:
              deploy:
                steps:
                  - download: current
                    artifact: 'drop'
                  - task: AzureWebApp@1
                    inputs:
                      azureSubscription: 'Azure-ServiceConnection'
                      appName: '${{ env.appService }}'
                      resourceGroupName: '${{ env.resourceGroup }}'
                      package: '$(Pipeline.Workspace)/drop/**/*.zip'

One template, three stages generated. Add a fourth environment? Just add an entry to the environments list — no template changes needed.

Conditional Insertion with ${{ if }}

# File: templates/jobs/build-matrix.yml
# Template that conditionally adds platforms to a build matrix

parameters:
  - name: includeWindows
    type: boolean
    default: true
  - name: includeLinux
    type: boolean
    default: true
  - name: includeMac
    type: boolean
    default: false

jobs:
  - ${{ if eq(parameters.includeLinux, true) }}:
    - job: Build_Linux
      pool:
        vmImage: 'ubuntu-latest'
      steps:
        - script: echo "Building on Linux"

  - ${{ if eq(parameters.includeWindows, true) }}:
    - job: Build_Windows
      pool:
        vmImage: 'windows-latest'
      steps:
        - script: echo "Building on Windows"

  - ${{ if eq(parameters.includeMac, true) }}:
    - job: Build_Mac
      pool:
        vmImage: 'macos-latest'
      steps:
        - script: echo "Building on macOS"

Combining Iteration with Conditionals

# File: templates/steps/notify.yml
# Generate notification steps based on a list of channels

parameters:
  - name: channels
    type: object
    default:
      - type: slack
        webhook: '$(SLACK_WEBHOOK)'
      - type: teams
        webhook: '$(TEAMS_WEBHOOK)'
  - name: status
    type: string
    default: 'succeeded'

steps:
  - ${{ each channel in parameters.channels }}:
    - ${{ if eq(channel.type, 'slack') }}:
      - script: |
          curl -X POST -H 'Content-type: application/json' \
            --data '{"text":"Pipeline ${{ parameters.status }}"}' \
            ${{ channel.webhook }}
        displayName: 'Notify Slack'

    - ${{ if eq(channel.type, 'teams') }}:
      - script: |
          curl -X POST -H 'Content-type: application/json' \
            --data '{"text":"Pipeline ${{ parameters.status }}"}' \
            ${{ channel.webhook }}
        displayName: 'Notify Teams'
Compile-time vs runtime: Template expressions (${{ }}) resolve at compile time — they generate YAML. Runtime expressions ($[ ]) evaluate during execution. Use template expressions for structural decisions (which stages/jobs/steps exist) and runtime expressions for value decisions (which branch to deploy).

Shared Template Repositories

In real organizations, templates don't live in each repo — they live in a central template repository shared across all projects. This enables versioning, code review, and controlled rollouts of template changes.

Template Repository Structure

# Recommended structure for a shared template repo
# Repository: Platform/pipeline-templates

pipeline-templates/
├── README.md                    # Documentation and usage guide
├── CHANGELOG.md                 # Version history
├── templates/
│   ├── steps/
│   │   ├── dotnet-build.yml     # .NET build + test steps
│   │   ├── docker-build.yml     # Container build + push steps
│   │   ├── npm-build.yml        # Node.js build + test steps
│   │   └── security-scan.yml    # Mandatory security scanning
│   ├── jobs/
│   │   ├── build-publish.yml    # Build + publish artifacts
│   │   └── integration-test.yml # Integration test job
│   ├── stages/
│   │   ├── deploy-appservice.yml  # App Service deployment
│   │   ├── deploy-aks.yml         # Kubernetes deployment
│   │   └── deploy-functions.yml   # Azure Functions deployment
│   ├── variables/
│   │   ├── dev.yml              # Dev environment config
│   │   ├── staging.yml          # Staging environment config
│   │   └── production.yml       # Production environment config
│   └── pipelines/
│       ├── dotnet-webapp.yml    # Extends: full .NET web app pipeline
│       └── container-app.yml    # Extends: containerized app pipeline
└── examples/
    ├── simple-dotnet.yml        # Example: consuming .NET templates
    └── multi-stage.yml          # Example: full multi-stage pipeline

Referencing Templates from Another Repository

# File: azure-pipelines.yml (in your application repo)
# Consuming templates from a shared central repository

trigger:
  branches:
    include: [main]

# Declare the external template repository
resources:
  repositories:
    - repository: templates          # Alias used in template references
      type: git                      # Azure Repos Git
      name: Platform/pipeline-templates  # Project/RepoName
      ref: refs/tags/v2.1.0          # Pin to a specific tag!

variables:
  - template: templates/variables/dev.yml@templates

stages:
  - stage: Build
    jobs:
      - template: templates/jobs/build-publish.yml@templates
        parameters:
          buildName: 'MyApp'
          projectPath: 'src/MyApp.csproj'

  - template: templates/stages/deploy-appservice.yml@templates
    parameters:
      environment: 'dev'
      appServiceName: '$(appServiceName)'
      resourceGroup: '$(resourceGroup)'
      dependsOn: ['Build']

The @templates suffix tells Azure Pipelines to look in the declared repository alias, not the current repo.

Version pinning: Pin template references to Git tags (not branches) for stability. Use ref: refs/tags/v2.1.0 so pipeline behavior doesn't change unexpectedly when the template repo is updated. Teams can upgrade by bumping the tag reference in their pipeline YAML.

Cross-Project References

# Referencing templates from a different Azure DevOps project
resources:
  repositories:
    - repository: shared
      type: git
      name: InfraProject/shared-pipelines   # Different project
      ref: refs/tags/v3.0.0

# For GitHub-hosted templates:
resources:
  repositories:
    - repository: github_templates
      type: github
      name: my-org/pipeline-templates
      endpoint: 'GitHub-ServiceConnection'
      ref: refs/tags/v1.0.0

Required Templates (Security Policy)

Required templates are the enforcement mechanism — a project-level policy that prevents any pipeline from running unless it extends an approved template. This is the governance tool that makes extends templates meaningful at scale.

How It Works

  1. Platform team creates an extends template with mandatory security steps
  2. Admin configures "Required YAML templates" in Project Settings → Pipelines → Settings
  3. Policy specifies: the required template file path and the repository it lives in
  4. Any pipeline that doesn't extends the required template will be blocked from running

Configuration Steps

# The required template policy is configured in the UI:
# Project Settings → Pipelines → Settings → "Required YAML templates"
#
# Configuration:
#   Repository: Platform/pipeline-templates
#   Path: templates/pipelines/dotnet-webapp.yml
#   Ref: refs/tags/v2.1.0
#   Applies to: All pipelines (or specific folders/repos)
#
# Result:
#   Pipelines without `extends:` pointing to this template → BLOCKED
#   Pipelines that extend the approved template → ALLOWED
Zero-trust pipeline governance: Required templates create a security boundary that individual developers cannot bypass. Even if a developer removes the security scanning steps from their pipeline YAML, the required template policy will reject the pipeline run entirely. The only path forward is to use the approved template.

Governance Architecture

Required Templates Enforcement Flow
flowchart TD
    A[Developer pushes code] --> B[Pipeline triggered]
    B --> C{Uses required template?}
    C -->|Yes| D[Pipeline executes]
    C -->|No| E[Pipeline BLOCKED]
    D --> F[Mandatory security steps run]
    F --> G["Team's custom steps run"]
    G --> H[Mandatory post-steps run]
    H --> I[Pipeline completes]
                            

Case Study: Enterprise Template Library

Case Study Enterprise — 200 engineers, 80+ repositories

Building a Shared Template Library at Scale

Challenge: A financial services company with 200 engineers across 80+ repositories had inconsistent CI/CD practices. Security scanning was optional (and often skipped). Deployment procedures varied wildly — some teams had 3-stage pipelines, others pushed directly to production. A compliance audit flagged 40% of pipelines as non-compliant.

Solution: Centralized Template Library

Phase 1 — Foundation (Weeks 1–2):

  • Created Platform/pipeline-templates repository with step, job, and stage templates
  • Built templates for the three primary tech stacks: .NET, Node.js, and Python
  • Established versioning via Git tags (semver: v1.0.0, v1.1.0, v2.0.0)

Phase 2 — Migration (Weeks 3–6):

  • Migrated 20 "early adopter" repos to use shared templates
  • Created an extends template with mandatory credential scanning + SBOM
  • Published migration guide with before/after examples

Phase 3 — Enforcement (Weeks 7–8):

  • Enabled "Required YAML templates" policy for all new pipelines
  • Gave existing pipelines a 4-week grace period to migrate
  • Provided office hours and PR reviews for teams needing help
Results After 3 Months
  • 70% reduction in total pipeline YAML across the organization
  • 100% compliance with security scanning requirements
  • 15-minute average to set up CI/CD for a new repository (from 2+ hours)
  • Single PR to roll out pipeline improvements to all 80 repos (update the template, teams bump the tag)
  • Zero security audit findings related to CI/CD in subsequent audits
Templates Governance Security Scale

Template Versioning Strategy

# Versioning strategy for the shared template repository:
#
# MAJOR (v2.0.0) — Breaking changes:
#   - Renamed parameters
#   - Removed template files
#   - Changed parameter types
#   Teams MUST update their pipelines when bumping major versions
#
# MINOR (v2.1.0) — Backwards-compatible additions:
#   - New optional parameters (with defaults)
#   - New template files
#   - New steps added to extends template
#   Teams can bump safely without pipeline changes
#
# PATCH (v2.1.1) — Bug fixes:
#   - Fixed typos in display names
#   - Corrected task version references
#   - Documentation updates
#
# Tagging workflow:
#   git tag -a v2.1.0 -m "Add Node.js 20 support, add Docker layer caching"
#   git push origin v2.1.0

Exercises

Exercise 1 Difficulty: Beginner

Step Templates for Build & Test

Goal: Create reusable step templates and consume them from two different pipelines.

  1. Create templates/steps/npm-build.yml — a step template that installs Node.js, runs npm ci, and runs npm run build. Accept parameters for nodeVersion (default: '20.x') and workingDirectory (default: '.').
  2. Create templates/steps/npm-test.yml — a step template that runs npm test and publishes test results. Accept a workingDirectory parameter.
  3. Create two pipelines (pipeline-frontend.yml and pipeline-api.yml) that both use these templates with different workingDirectory values.

Success criteria: Both pipelines use the same template files. Changing the template updates both pipelines.

Exercise 2 Difficulty: Intermediate

Stage Template for Deployment

Goal: Build a deploy stage template and compose a 3-environment pipeline.

  1. Create templates/stages/deploy-webapp.yml with parameters for: environment, appServiceName, resourceGroup, dependsOn.
  2. The template should use a deployment job with runOnce strategy.
  3. Create a pipeline that uses this template three times — dev, staging, production — with appropriate dependsOn chains.
  4. Add a condition: production only deploys from the main branch.

Success criteria: Pipeline shows 3 sequential stages. Production is skipped on non-main branches.

Exercise 3 Difficulty: Advanced

Extends Template with Enforced Scanning

Goal: Create an extends template that mandates security steps and test consuming it.

  1. Create templates/pipelines/secure-build.yml — an extends template with:
    • Mandatory CredScan step before team steps
    • A buildSteps parameter (type: stepList) for team code
    • Mandatory SBOM generation after team steps
  2. Create a consuming pipeline that extends this template and provides build steps for a .NET application.
  3. Verify that removing the extends: block (switching to direct YAML) would be blocked by policy.

Success criteria: Consuming pipeline cannot remove mandatory steps. Security scanning always runs.

Exercise 4 Difficulty: Advanced

Shared Template Repository with Version Tagging

Goal: Set up a multi-repo template consumption pattern with version control.

  1. Create a new Azure Repos repository called pipeline-templates.
  2. Add step, job, and stage templates with a templates/ folder structure.
  3. Create a Git tag v1.0.0 on the initial commit.
  4. In a separate application repo, reference the template repo with ref: refs/tags/v1.0.0.
  5. Make a change to the template repo, create tag v1.1.0, and verify the consuming pipeline still uses v1.0.0 until you update the ref.

Success criteria: Template changes don't affect consuming pipelines until they explicitly bump the version tag.

Next in the Bootcamp

In Module 7: Multi-Stage Pipelines & Deployment, we'll cover deployment strategies (rolling, canary, blue-green), environment approvals and gates, service connections, deployment jobs, and building production-grade release pipelines with rollback capabilities.