You have a deploymentList parameter. Each deployment needs a different Azure environment name, approval group, and service connection. The schema won’t let you add custom properties to a deployment definition. The parallel-array workaround you wrote last month is already out of sync with the deployment list it describes.
jobList, deploymentList, and stageList parameter types accept valid YAML definitions — which means Azure DevOps enforces the schema and rejects any custom property you attach directly. Before templateContext, the only solution was a parallel parameter array: one array of job definitions, one array of metadata objects, indexed in lockstep. That pattern breaks silently when an item is added to one array but not the other. There is no error. The wrong metadata silently maps to the wrong job.
templateContext solves this. This article covers:
- The exact syntax for attaching metadata to jobs and stages.
- How to read
templateContextproperties inside a template that processes a list. - The scope constraints that prevent
templateContextfrom being read insidesteps:— and the workaround. - Four production patterns: environment routing, per-job conditional steps, variable bridging, and multi-tenant regional deployment.
- A decision framework for when
templateContextis the right tool vs. a plainobjectparameter.
Compatibility: templateContext is fully supported on Azure DevOps Services. On Azure DevOps Server, support begins in version 2022. Earlier Server versions will surface an “Unexpected value ’templateContext’” parse error.
templateContext Mechanics
What templateContext Is and Where It Lives
templateContext is a reserved property on job, deployment, and stage definitions. Azure DevOps’s YAML parser recognizes it explicitly — it does not trigger a schema validation error even though it is not part of the standard job or stage schema. Any YAML mapping you place inside templateContext: is accepted without type checking.
Place templateContext: as a sibling of steps:, pool:, and environment: inside a job definition:
# pipeline.yml — deployment with templateContext metadata
stages:
- template: templates/deploy-jobs.yml
parameters:
deployments:
- deployment: DeployDev
pool:
vmImage: ubuntu-latest
templateContext: # sibling of strategy:, pool:, environment:
targetEnvironment: dev
serviceConnection: sc-dev
approvalGroup: dev-approvers
runSmokeTests: false
strategy:
runOnce:
deploy:
steps:
- script: echo "Building artifacts"
displayName: 'Build'
After compilation, templateContext is stripped from the Expanded YAML. The job that the agent receives contains no trace of it:
# Expanded YAML — templateContext is absent; the agent never sees it
jobs:
- deployment: DeployDev
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: echo "Building artifacts"
displayName: 'Build'
templateContext has no runtime existence. It is purely a compile-time data carrier — available during template expansion (Phase 2 of initialization) and gone afterward.
The Access Scope Rule
templateContext properties are accessible exclusively inside a ${{ each }} loop in the template file that receives the jobList or deploymentList. Access uses compile-time expression syntax:
# CORRECT: read templateContext inside the ${{ each }} loop in the template
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
environment:
name: ${{ deploy.templateContext.targetEnvironment }} # compile-time access
templateContext does not exist inside the job’s steps: at runtime. The agent executes steps after compilation — by that point templateContext has been stripped. Any attempt to read ${{ deploy.templateContext.myProp }} inside a steps: block at runtime returns an empty string or causes an evaluation error.
Three patterns bridge the compile-time/runtime gap:
Pattern 1 — Job-level variables injection (most common):
# templates/deploy-jobs.yml
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
variables:
# Extract templateContext values into pipeline variables at compile time
- name: TARGET_ENV
value: ${{ deploy.templateContext.targetEnvironment }}
- name: SERVICE_CONN
value: ${{ deploy.templateContext.serviceConnection }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- ${{ deploy.strategy.runOnce.deploy.steps }}
- script: echo "Deployed to $(TARGET_ENV) via $(SERVICE_CONN)"
displayName: 'Report'
Pattern 2 — env: block on a specific step:
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Region: $REGION"
./deploy.sh --region "$REGION"
displayName: 'Deploy'
env:
REGION: ${{ deploy.templateContext.region }} # injected as env var
Pattern 3 — Parameter passed to an inner template:
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
strategy:
runOnce:
deploy:
steps:
- template: steps/deploy-steps.yml
parameters:
# Pass the templateContext value as a typed parameter to the inner template
targetEnvironment: ${{ deploy.templateContext.targetEnvironment }}
serviceConnection: ${{ deploy.templateContext.serviceConnection }}
Use Pattern 1 (job-level variables) when multiple steps in the job need the value. Use Pattern 2 (env: block) when only one step needs it and you want to keep the scope narrow. Use Pattern 3 (inner template parameter) when the inner template is shared and typed validation is important.
Diagram: Metadata Propagation with templateContext
This diagram visualizes the flow of metadata from its definition in the pipeline root to its consumption in a template, and finally its propagation to the runtime environment via the Variable Bridge pattern.
Visual Notes:
- The Bridge: Because
templateContextis stripped during compilation, the template must “bridge” the value into a standard variable or environment variable for the agent to see it. - Scope Restriction: The blue zone is the only place where direct
${{ deploy.templateContext }}access is valid. In the orange zone (Runtime), only the bridged variables exist.
Schema Validation Behavior
Azure DevOps recognizes templateContext as a reserved keyword. Adding it to a job definition inside a deploymentList parameter does not trigger the standard “Unexpected value” schema error:
# INVALID: custom property added directly to a job definition — schema error
- deployment: DeployDev
targetEnvironment: dev # "Unexpected value 'targetEnvironment'"
strategy:
runOnce:
deploy:
steps:
- script: echo "deploy"
# VALID: same property moved inside templateContext — no schema error
- deployment: DeployDev
templateContext:
targetEnvironment: dev # accepted without schema validation
strategy:
runOnce:
deploy:
steps:
- script: echo "deploy"
The content inside templateContext: is entirely unvalidated. Azure DevOps accepts any YAML mapping — misspelled property names, wrong value types, and absent required properties all pass without parse errors. The errors surface when properties are consumed: ${{ deploy.templateContext.typo }} evaluates to an empty string silently; ${{ coalesce(deploy.templateContext.typo, 'fallback') }} applies the fallback.
templateContext is valid on jobs and stages passed via jobList and stageList parameters. It is also valid on deployment jobs passed via deploymentList. It is not valid on steps passed via stepList. It is also not valid on jobs or stages defined directly in the pipeline root YAML — those are not processed by a ${{ each }} loop, and the parser surfaces an “Unexpected value ’templateContext’” error if used there.
templateContext with deploymentList
The Basic Pattern
The pipeline file defines a deploymentList parameter and passes a list of deployment definitions, each with a templateContext: block. The template file receives the list and iterates with ${{ each deploy in parameters.deployments }}:
# pipeline.yml — two deployments, each with targetEnvironment in templateContext
stages:
- template: templates/deploy-jobs.yml
parameters:
deployments:
- deployment: DeployDev
templateContext:
targetEnvironment: dev
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploy dev"
displayName: 'Deploy'
- deployment: DeployProd
templateContext:
targetEnvironment: prod
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploy prod"
displayName: 'Deploy'
# templates/deploy-jobs.yml — iterates and routes to the correct Azure environment
parameters:
- name: deployments
type: deploymentList
default: []
jobs:
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
# Set the Azure environment from per-job templateContext
environment:
name: ${{ deploy.templateContext.targetEnvironment }}
resourceName: myapp-${{ deploy.templateContext.targetEnvironment }}
strategy: ${{ deploy.strategy }}
pool: ${{ deploy.pool }}
Subsequent properties (environment:) map specific metadata into the job definition. Explicit property mapping (deployment: ${{ deploy.deployment }}, strategy: ${{ deploy.strategy }}) avoids “duplicate key” schema errors that occur when using the raw - ${{ deploy }} merge syntax.
Production Pattern 1 — Environment Routing
Each job in the list targets a different Azure DevOps environment — with its own approval gates, service connection, and notification channel. All three pieces of per-job metadata travel with the job definition:
# pipeline.yml — three deployment jobs, each with full routing metadata
stages:
- stage: Deploy
jobs:
- template: templates/deploy-job.yml
parameters:
containerRegistry: myregistry.azurecr.io
imageTag: $(Build.BuildId)
deployments:
- deployment: DeployDev
templateContext:
environment: dev
serviceConnection: sc-dev
approvalGroup: dev-approvers
resourceGroup: rg-myapp-dev
strategy:
runOnce:
deploy:
steps:
- script: echo "Pre-deploy checks"
displayName: 'Pre-flight'
- deployment: DeployStaging
templateContext:
environment: staging
serviceConnection: sc-staging
approvalGroup: platform-team
resourceGroup: rg-myapp-staging
strategy:
runOnce:
deploy:
steps:
- script: echo "Pre-deploy checks"
displayName: 'Pre-flight'
- deployment: DeployProd
templateContext:
environment: prod
serviceConnection: sc-prod
approvalGroup: security-team
resourceGroup: rg-myapp-prod
strategy:
runOnce:
deploy:
steps:
- script: echo "Pre-deploy checks"
displayName: 'Pre-flight'
# templates/deploy-job.yml — reads all four templateContext properties per job
parameters:
- name: containerRegistry
type: string
- name: imageTag
type: string
- name: deployments
type: deploymentList
default: []
jobs:
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
displayName: 'Deploy to ${{ deploy.templateContext.environment }}'
# Azure DevOps environment — controls approval gates and audit log
environment:
name: ${{ deploy.templateContext.environment }}
resourceName: myapp-${{ deploy.templateContext.environment }}
variables:
# Bridge templateContext values into runtime variables for script steps
- name: RESOURCE_GROUP
value: ${{ deploy.templateContext.resourceGroup }}
- name: DEPLOY_ENV
value: ${{ deploy.templateContext.environment }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- ${{ deploy.strategy.runOnce.deploy.steps }}
- task: AzureCLI@2
displayName: 'Deploy container'
inputs:
azureSubscription: ${{ deploy.templateContext.serviceConnection }}
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az webapp config container set \
--resource-group "$(RESOURCE_GROUP)" \
--name "myapp-$(DEPLOY_ENV)" \
--docker-custom-image-name "${{ parameters.containerRegistry }}/myapp:${{ parameters.imageTag }}"
Without templateContext, routing each job to a different environment requires either hardcoding the environment name in the template or maintaining a parallel metadata array. With templateContext, the relationship between job identity and deployment target is explicit in the pipeline file.
Production Pattern 2 — Per-Job Conditional Step Injection
A templateContext boolean property can drive a compile-time ${{ if }} block inside the template to add or remove steps for that specific job. The caller annotates individual jobs as needing additional steps without modifying the template:
# pipeline.yml — only prod gets smoke tests
stages:
- template: templates/deploy-job.yml
parameters:
deployments:
- deployment: DeployDev
templateContext:
environment: dev
serviceConnection: sc-dev
runSmokeTests: false # no smoke tests for dev
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploy dev"
- deployment: DeployProd
templateContext:
environment: prod
serviceConnection: sc-prod
runSmokeTests: true # inject smoke test steps for prod
smokeTestUrl: https://myapp.prod.example.com
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploy prod"
# templates/deploy-job.yml — conditional step injection
parameters:
- name: deployments
type: deploymentList
default: []
jobs:
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
environment:
name: ${{ deploy.templateContext.environment }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- ${{ deploy.strategy.runOnce.deploy.steps }}
# Smoke tests are injected for this specific job at compile time
- ${{ if deploy.templateContext.runSmokeTests }}:
- script: |
echo "Running smoke tests against $SMOKE_URL"
./smoke-tests.sh --url "$SMOKE_URL" --timeout 120
displayName: 'Smoke tests'
env:
SMOKE_URL: ${{ coalesce(deploy.templateContext.smokeTestUrl, 'http://localhost') }}
- ${{ if deploy.templateContext.runSmokeTests }}:
- task: PublishTestResults@2
displayName: 'Publish smoke test results'
condition: always()
inputs:
testResultsFiles: '**/smoke-results.xml'
testRunTitle: 'Smoke Tests — ${{ deploy.templateContext.environment }}'
The ${{ if }} condition evaluates at compile time — either the smoke test steps appear in the Expanded YAML for that job or they do not. Adding smoke tests to a new environment requires only adding runSmokeTests: true to that job’s templateContext: in the pipeline file.
Production Pattern 3 — Per-Job Variable Injection
The job-level variables: bridge makes templateContext values available to every step in a job:
# templates/deploy-job.yml — full variable bridge pattern
parameters:
- name: deployments
type: deploymentList
default: []
jobs:
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
environment:
name: ${{ deploy.templateContext.environment }}
variables:
# All templateContext values the steps in this job need
- name: DEPLOY_ENV
value: ${{ deploy.templateContext.environment }}
- name: RESOURCE_GROUP
value: ${{ coalesce(deploy.templateContext.resourceGroup, 'rg-default') }}
- name: NOTIFICATION_CHANNEL
value: ${{ coalesce(deploy.templateContext.notificationChannel, 'deployments') }}
- name: APPROVAL_GROUP
value: ${{ deploy.templateContext.approvalGroup }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- ${{ deploy.strategy.runOnce.deploy.steps }}
- script: |
echo "Environment: $(DEPLOY_ENV)"
echo "Resource group: $(RESOURCE_GROUP)"
echo "Notifying channel: $(NOTIFICATION_CHANNEL)"
displayName: 'Deployment summary'
- task: InvokeRestAPI@1
displayName: 'Notify Slack'
inputs:
connectionType: connectedServiceName
serviceConnection: sc-slack
method: POST
body: |
{"channel": "$(NOTIFICATION_CHANNEL)", "text": "Deployed to $(DEPLOY_ENV)"}
Variables declared in the job-level variables: block are readable by all steps in the job via $(VARIABLE_NAME) macro expansion. The compile-time expression resolves the templateContext value before the variable is declared at runtime.
templateContext with stageList
Stage-Level Metadata
templateContext works identically on stage definitions passed via a stageList parameter. Place it as a sibling of jobs: inside the stage block:
# pipeline.yml — stages carrying region metadata
stages:
- template: templates/regional-deploy.yml
parameters:
deployStages:
- stage: DeployUSEast
templateContext:
region: us-east
complianceTier: standard
notificationChannel: deployments-us
jobs:
- deployment: Deploy
pool:
vmImage: ubuntu-latest
environment: us-east
strategy:
runOnce:
deploy:
steps:
- script: ./deploy.sh
- stage: DeployEUWest
templateContext:
region: eu-west
complianceTier: gdpr # GDPR compliance tier — affects audit steps
notificationChannel: deployments-eu
jobs:
- deployment: Deploy
pool:
vmImage: ubuntu-latest
environment: eu-west
strategy:
runOnce:
deploy:
steps:
- script: ./deploy.sh
The iterating template accesses stage-level metadata via ${{ stage.templateContext.propertyName }}:
# templates/regional-deploy.yml
parameters:
- name: deployStages
type: stageList
default: []
stages:
- ${{ each stage in parameters.deployStages }}:
- stage: ${{ stage.stage }}
displayName: 'Deploy to ${{ stage.templateContext.region }}'
variables:
# Bridge stage-level metadata into runtime variables
- name: DEPLOY_REGION
value: ${{ stage.templateContext.region }}
- name: COMPLIANCE_TIER
value: ${{ stage.templateContext.complianceTier }}
jobs:
# Conditionally inject GDPR audit job for compliant stages
- ${{ if eq(stage.templateContext.complianceTier, 'gdpr') }}:
- job: GDPRAudit
pool:
vmImage: ubuntu-latest
steps:
- script: ./gdpr-audit.sh --region "$(DEPLOY_REGION)"
displayName: 'GDPR compliance audit'
- ${{ each job in stage.jobs }}:
- ${{ job }}
Stage-level templateContext suits metadata that applies to the entire stage: geographic region, compliance tier, release track, or notification routing. Per-job variation within the stage uses job-level templateContext on jobs inside the stage’s jobs: list.
Production Pattern 4 — Multi-Tenant Regional Deployment
A platform team defines one stage template that handles any region. Callers pass a stageList where each stage carries templateContext with the region-specific configuration. Adding a new region requires one new entry in the pipeline file — the template is unchanged:
# pipeline.yml — three regional stages; each stage is fully self-describing
stages:
- template: templates/tenant-deploy.yml
parameters:
config: # shared settings (global object param)
containerRegistry: myregistry.azurecr.io
imageTag: $(Build.BuildId)
appName: myplatform
deployStages: # per-region metadata (templateContext)
- stage: DeployAPAC
templateContext:
region: australiaeast
tenantId: tenant-apac
serviceConnection: sc-apac-prod
approvalRequired: true
smokeTestUrl: https://apac.myplatform.example.com
jobs:
- job: PreDeploy
pool: { vmImage: ubuntu-latest }
steps: [ script: echo "APAC pre-deploy" ]
- stage: DeployEMEA
templateContext:
region: westeurope
tenantId: tenant-emea
serviceConnection: sc-emea-prod
approvalRequired: true
smokeTestUrl: https://emea.myplatform.example.com
jobs:
- job: PreDeploy
pool: { vmImage: ubuntu-latest }
steps: [ script: echo "EMEA pre-deploy" ]
- stage: DeployAmericas
templateContext:
region: eastus
tenantId: tenant-americas
serviceConnection: sc-americas-prod
approvalRequired: false
smokeTestUrl: https://us.myplatform.example.com
jobs:
- job: PreDeploy
pool: { vmImage: ubuntu-latest }
steps: [ script: echo "Americas pre-deploy" ]
# templates/tenant-deploy.yml — single template handles any number of regions
parameters:
- name: config
type: object
default:
containerRegistry: ''
imageTag: latest
appName: myapp
- name: deployStages
type: stageList
default: []
stages:
- ${{ each stage in parameters.deployStages }}:
- stage: ${{ stage.stage }}
displayName: 'Deploy to ${{ stage.templateContext.region }}'
variables:
# Bridge stage templateContext values to runtime variables
- name: DEPLOY_REGION
value: ${{ stage.templateContext.region }}
- name: TENANT_ID
value: ${{ stage.templateContext.tenantId }}
jobs:
# Approval gate — injected only for stages where approvalRequired: true
- ${{ if stage.templateContext.approvalRequired }}:
- job: ApprovalGate
pool: server # server job runs no agent
timeoutInMinutes: 4320 # 3-day approval window
steps:
- task: ManualValidation@0
displayName: 'Approve ${{ stage.templateContext.region }} deployment'
inputs:
instructions: |
Review and approve deployment to ${{ stage.templateContext.region }}
Tenant: ${{ stage.templateContext.tenantId }}
Image: ${{ parameters.config.containerRegistry }}/${{ parameters.config.appName }}:${{ parameters.config.imageTag }}
onTimeout: reject
# Inject the caller's jobs directly
- ${{ each job in stage.jobs }}:
- ${{ job }}
# Deployment job — injected dynamically for every stage
- deployment: Deploy_${{ stage.templateContext.tenantId }}
displayName: 'Deploy ${{ parameters.config.appName }} to ${{ stage.templateContext.region }}'
${{ if stage.templateContext.approvalRequired }}:
dependsOn: ApprovalGate
environment:
name: ${{ stage.templateContext.tenantId }}-${{ stage.templateContext.region }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
displayName: 'Deploy container to ${{ stage.templateContext.region }}'
inputs:
azureSubscription: ${{ stage.templateContext.serviceConnection }}
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az webapp config container set \
--resource-group "rg-$(TENANT_ID)-$(DEPLOY_REGION)" \
--name "${{ parameters.config.appName }}-$(TENANT_ID)" \
--docker-custom-image-name \
"${{ parameters.config.containerRegistry }}/${{ parameters.config.appName }}:${{ parameters.config.imageTag }}"
- script: |
curl -f "$SMOKE_URL/health" || exit 1
displayName: 'Smoke test'
env:
SMOKE_URL: ${{ stage.templateContext.smokeTestUrl }}
Adding a new region requires one new entry in the deployStages list. The approvalRequired, serviceConnection, tenantId, and region metadata travel with the stage. The template is unchanged.
Nesting templateContext — Jobs Inside Stages
A stage definition can carry stage-level templateContext while its jobs carry their own job-level templateContext. The two are independent and do not interfere:
# pipeline.yml — stage-level and job-level templateContext coexist
- template: templates/deploy.yml
parameters:
deployStages:
- stage: DeployUSEast
templateContext:
region: us-east # stage-level: applies to the whole stage
jobs:
- deployment: DeployFrontend
templateContext:
role: frontend # job-level: applies to this job only
port: 443
environment: frontend-us
strategy:
runOnce:
deploy:
steps:
- script: ./deploy-frontend.sh
- deployment: DeployAPI
templateContext:
role: api
port: 8080
environment: api-us
strategy:
runOnce:
deploy:
steps:
- script: ./deploy-api.sh
# templates/deploy.yml — reads both levels correctly
parameters:
- name: deployStages
type: stageList
default: []
stages:
- ${{ each stage in parameters.deployStages }}:
- stage: ${{ stage.stage }}
variables:
- name: STAGE_REGION
value: ${{ stage.templateContext.region }} # stage-level
jobs:
- ${{ each deploy in stage.jobs }}:
- deployment: ${{ deploy.deployment }}
variables:
- name: JOB_ROLE
value: ${{ deploy.templateContext.role }} # job-level
- name: SERVICE_PORT
value: ${{ deploy.templateContext.port }}
environment: ${{ deploy.environment }}
strategy: ${{ deploy.strategy }}
Do not duplicate stage-level properties in every job’s templateContext. If a value applies to all jobs in a stage, declare it on the stage’s templateContext and propagate it downward via the stage-level variables: block — jobs inherit those variables at runtime via $(VARIABLE_NAME).
templateContext vs. object Parameter — The Decision Framework
What Each Tool Is For
templateContext is for per-item metadata — properties that vary between individual jobs or stages in a list. Each item carries exactly the metadata it needs and nothing more.
An object parameter is for template-wide configuration — a single bundle that applies to the entire template invocation, shared equally by all items.
Confusing the two creates problems in either direction. A single flat object parameter that grows a new property every time a new per-job variation is needed becomes a sprawling schema with dozens of prefixed properties (devServiceConnection, stagingServiceConnection, prodServiceConnection). templateContext carrying global configuration — the container registry URL, the build number — forces every job entry to repeat the same value.
templateContext | object parameter | |
|---|---|---|
| Scope | Per item in a list (one job or stage) | Entire template invocation |
| Declaration location | Inside the job or stage definition | In the calling pipeline’s parameters: block |
| Access syntax | ${{ job.templateContext.prop }} inside ${{ each }} | ${{ parameters.config.prop }} anywhere in the template |
| New item added | No template change needed — new item brings its own metadata | No template change needed — shared config unchanged |
| New shared setting added | Edit every item’s templateContext: block | Edit the global config default once |
| Schema validation | None — fully unvalidated | None — object type is unvalidated |
| Best use case | Deployment target, approval group, service connection, per-environment URL | Container registry, image tag, feature flags, org-wide settings |
Combining Both
The two tools are complementary. A template can receive a global config object parameter for shared settings and a deploymentList where each job’s templateContext carries only the per-job overrides:
# pipeline.yml — global config + per-job templateContext
stages:
- template: templates/deploy-jobs.yml
parameters:
config: # shared: same for all jobs
containerRegistry: myregistry.azurecr.io
imageTag: $(Build.BuildId)
buildNumber: $(Build.BuildNumber)
notifyOnFailure: true
deployments: # per-job: unique per environment
- deployment: DeployDev
templateContext:
environment: dev
serviceConnection: sc-dev
replicaCount: 1
strategy:
runOnce:
deploy:
steps:
- script: echo "Pre-deploy checks"
- deployment: DeployProd
templateContext:
environment: prod
serviceConnection: sc-prod
replicaCount: 5
runSmokeTests: true
strategy:
runOnce:
deploy:
steps:
- script: echo "Pre-deploy checks"
# templates/deploy-jobs.yml — reads both global config and per-job templateContext
parameters:
- name: config
type: object
default:
containerRegistry: ''
imageTag: latest
buildNumber: ''
notifyOnFailure: false
- name: deployments
type: deploymentList
default: []
jobs:
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
environment:
name: ${{ deploy.templateContext.environment }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- ${{ deploy.strategy.runOnce.deploy.steps }}
- task: AzureCLI@2
displayName: 'Deploy to ${{ deploy.templateContext.environment }}'
inputs:
azureSubscription: ${{ deploy.templateContext.serviceConnection }}
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
# Global config: shared registry and tag
IMAGE="${{ parameters.config.containerRegistry }}/myapp:${{ parameters.config.imageTag }}"
# Per-job templateContext: unique replica count per environment
REPLICAS="${{ deploy.templateContext.replicaCount }}"
az aks scale \
--resource-group "rg-myapp-${{ deploy.templateContext.environment }}" \
--name "aks-${{ deploy.templateContext.environment }}" \
--node-count "$REPLICAS"
- ${{ if deploy.templateContext.runSmokeTests }}:
- script: ./smoke-tests.sh
displayName: 'Smoke tests'
The config object carries the values that do not vary by deployment target. The templateContext carries the values that make each deployment unique. Neither grows unnecessarily when the other changes.
When Neither Is the Right Tool
Use a runtime condition: field with $[ ] expressions when the decision depends on a value that only exists at runtime — such as the result of a previous job, a release pipeline variable, or a quality gate score:
# Runtime condition — templateContext cannot evaluate this
- job: DeployProd
dependsOn: QualityGate
condition: and(succeeded('QualityGate'), eq(dependencies.QualityGate.outputs['gate.passed'], 'true'))
steps:
- script: ./deploy.sh
templateContext is a compile-time mechanism. It cannot read variables set by ##vso[task.setvariable] inside a running job, and it cannot react to runtime conditions. If the routing decision depends on a value that does not exist until a job or step executes, use condition: or dependsOn instead.
Never store secrets in templateContext. The content is plain text in source-controlled YAML. Service principal passwords, API keys, and connection strings belong in variable groups, Azure Key Vault references ($(az-keyvault-secret)), or service connections — not in templateContext: blocks that anyone with repository read access can see.
Decision flow:
- Is the value known at pipeline-definition time (author time, not runtime)?
- No → use
condition:with$[ ]runtime expression - Yes → continue
- No → use
- Does the value vary per job or stage in the list?
- Yes →
templateContext - No →
objectparameter
- Yes →
- Is the value a secret?
- Yes → variable group or Key Vault; never
templateContextorobjectparam in plaintext
- Yes → variable group or Key Vault; never
Hands-On Example: Multi-Environment Deployment with Per-Stage Approvals
Scenario: A platform team supports deployments to four environments: dev, test, staging, and prod. Each environment has a different Azure DevOps environment (with approval gates), a different service connection, and a different notification channel for deployment events. The prod environment additionally requires a smoke test job after deployment. The team wants a single reusable stage template driven by templateContext.
Prerequisites:
- Four Azure DevOps environments created and named:
dev,test,staging,prod - Four service connections named by convention:
sc-dev,sc-test,sc-staging,sc-prod - Familiarity with
stageListparameter type
Implementation:
Step 1 — Create templates/deploy-stage.yml with a stageList parameter:
# templates/deploy-stage.yml
parameters:
- name: containerRegistry
type: string
- name: imageTag
type: string
- name: deployStages
type: stageList
default: []
stages:
- ${{ each stage in parameters.deployStages }}:
- stage: ${{ stage.stage }}
displayName: 'Deploy — ${{ stage.templateContext.environment }}'
variables:
- name: DEPLOY_ENV
value: ${{ stage.templateContext.environment }}
- name: NOTIFICATION_CHANNEL
value: ${{ coalesce(stage.templateContext.notificationChannel, 'deployments') }}
jobs:
# Deployment job — always present
- deployment: DeployApp
displayName: 'Deploy to ${{ stage.templateContext.environment }}'
environment:
name: ${{ stage.templateContext.environment }}
resourceName: myapp-${{ stage.templateContext.environment }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
displayName: 'Deploy container image'
inputs:
azureSubscription: ${{ stage.templateContext.serviceConnection }}
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az webapp config container set \
--resource-group "rg-myapp-$(DEPLOY_ENV)" \
--name "myapp-$(DEPLOY_ENV)" \
--docker-custom-image-name \
"${{ parameters.containerRegistry }}/myapp:${{ parameters.imageTag }}"
echo "Deployed ${{ parameters.imageTag }} to $(DEPLOY_ENV)"
- task: InvokeRestAPI@1
displayName: 'Notify ${{ stage.templateContext.notificationChannel }}'
inputs:
connectionType: connectedServiceName
serviceConnection: sc-teams-webhook
method: POST
body: |
{"text": "Deployed to $(DEPLOY_ENV) — build ${{ parameters.imageTag }}"}
# Smoke test job — injected only when runSmokeTests: true
- ${{ if stage.templateContext.runSmokeTests }}:
- job: SmokeTests
displayName: 'Smoke tests — ${{ stage.templateContext.environment }}'
dependsOn: DeployApp
pool:
vmImage: ubuntu-latest
steps:
- script: |
echo "Running smoke tests against $SMOKE_URL"
for i in 1 2 3 4 5; do
curl -sf "$SMOKE_URL/health" && break || sleep 10
done
curl -f "$SMOKE_URL/health" || exit 1
displayName: 'Health check (with retry)'
env:
SMOKE_URL: ${{ stage.templateContext.smokeTestUrl }}
- task: PublishTestResults@2
displayName: 'Publish smoke results'
condition: always()
inputs:
testResultsFiles: '**/smoke-results.xml'
testRunTitle: 'Smoke — ${{ stage.templateContext.environment }}'
Step 2 — Define four stages in the calling pipeline:
# azure-pipelines.yml — 4 stages with templateContext
trigger:
- main
stages:
- template: templates/deploy-stage.yml
parameters:
containerRegistry: myregistry.azurecr.io
imageTag: $(Build.BuildId)
deployStages:
- stage: Dev
templateContext:
environment: dev
serviceConnection: sc-dev
notificationChannel: deployments-dev
runSmokeTests: false
jobs: [] # template provides the deployment job; no additional jobs needed
- stage: Test
dependsOn: Dev
templateContext:
environment: test
serviceConnection: sc-test
notificationChannel: deployments-test
runSmokeTests: false
jobs: []
- stage: Staging
dependsOn: Test
templateContext:
environment: staging
serviceConnection: sc-staging
notificationChannel: deployments-staging
runSmokeTests: false
jobs: []
- stage: Prod
dependsOn: Staging
templateContext:
environment: prod
serviceConnection: sc-prod
notificationChannel: deployments-prod
runSmokeTests: true
smokeTestUrl: https://myapp.prod.example.com
jobs: []
Verification:
- Pipeline graph shows four deployment stages with names
Dev,Test,Staging,Prod. - Expanded YAML shows the
SmokeTestsjob present in theProdstage and absent from the other three. - Each deployment task references the correct service connection for its stage.
- Adding a
uatstage requires one new entry in thedeployStageslist — zero changes totemplates/deploy-stage.yml.
Best Practices & Optimization
Do:
- Use
templateContextfor any property that varies per job or stage — keep variation data co-located with the item it describes. - Bridge
templateContextvalues into runtime steps via the job-levelvariables:injection pattern — it is the only reliable way to make compile-time metadata available to executing scripts. - Use
coalesce()for optional properties:${{ coalesce(job.templateContext.notificationChannel, 'deployments') }}. - Document the expected
templateContextschema in the template file’sparameters:block comments — it is the only discoverable reference for callers, sincetemplateContexthas no schema validation. - Combine
templateContextwith a globalobjectparameter:templateContextfor per-item variation,objectfor template-wide shared settings.
Don’t:
- Put secrets in
templateContext— the content is plain text in source-controlled YAML visible to anyone with repository read access; use variable groups or Azure Key Vault. - Access
templateContextfrom inside asteps:block — it does not exist at runtime; use the variable injection orenv:block pattern. - Use
templateContexton jobs defined directly in the pipeline root YAML (not inside a parameter) — it triggers an “Unexpected value ’templateContext’” parse error. - Replicate stage-level
templateContextvalues into every job in that stage — inject once at the stage level viavariables:and let jobs inherit.
Performance:
templateContext adds no runtime overhead. It is compiled away before the pipeline executes — no cost to adding many properties. However, deeply nested templateContext structures (objects containing objects) increase the YAML parser’s memory footprint during Phase 2. Keep templateContext flat (scalar values, not nested mappings) to stay well within the 20 MB parsing memory limit.
Security:
templateContext content is visible in the repository YAML file — treat it as public configuration, not a credentials store. A templateContext property used in a ${{ if }} condition controls pipeline structure at compile time. A Facade template with values: constraints on routing parameters provides the validation layer that templateContext itself cannot.
Troubleshooting Common Issues
Issue 1: “Unexpected value ’templateContext’” parse error
Cause: templateContext was added to a job defined directly in the pipeline root YAML (not inside a list parameter passed to a template), or the pipeline runs on Azure DevOps Server older than version 2022.
Solution: Move the job into a list parameter and process it with a ${{ each }} loop in a template file. For Server environments, verify the installed version is 2022 or later — the “Unexpected value” error is the exact error both scenarios produce.
Issue 2: ${{ deploy.templateContext.myProp }} resolves to an empty string
Cause: The property name is misspelled, the job entry in the pipeline file omits the templateContext: block entirely, or the property is absent from that specific job’s templateContext while present on others.
Solution: Use coalesce to handle absent properties gracefully and expose missing required properties with a validation block:
# Handle optional property with fallback
displayName: 'Deploy to ${{ coalesce(deploy.templateContext.environment, "MISSING-ENV") }}'
# Validate required property with a failing step
- ${{ if not(deploy.templateContext.serviceConnection) }}:
- script: |
echo "##vso[task.logissue type=error]templateContext.serviceConnection is required for deployment ${{ deploy.deployment }}"
exit 1
displayName: 'ERROR: Missing required templateContext.serviceConnection'
Issue 3: A templateContext value is needed inside a script step but is not accessible
Cause: templateContext does not exist at the runtime phase when steps: execute.
Solution: Use one of the three bridge patterns. For values needed by multiple steps, inject at the job level:
- ${{ each deploy in parameters.deployments }}:
- deployment: ${{ deploy.deployment }}
variables:
# Compile-time extraction into a runtime pipeline variable
- name: TARGET_ENV
value: ${{ deploy.templateContext.targetEnvironment }}
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to $(TARGET_ENV)" # readable at runtime
Issue 4: Stage-level templateContext values are not accessible inside the stage’s jobs
Cause: templateContext on a stage is readable in the template at the stage iteration level (${{ stage.templateContext.prop }}). It is not automatically available inside jobs defined within that stage.
Solution: At stage iteration time, inject the stage-level templateContext values into the stage’s variables: block. Jobs in the stage inherit those variables at runtime:
- ${{ each stage in parameters.deployStages }}:
- stage: ${{ stage.stage }}
variables:
# Stage-level variables inherited by all jobs in this stage
- name: STAGE_REGION
value: ${{ stage.templateContext.region }}
- name: STAGE_TENANT
value: ${{ stage.templateContext.tenantId }}
jobs:
- ${{ each job in stage.jobs }}:
- ${{ job }}
# Jobs can now read $(STAGE_REGION) and $(STAGE_TENANT) at runtime
Issue 5: Indentation of templateContext properties causes a parse error
Cause: The templateContext: block is not a parse error source itself, but its children must be indented exactly one level deeper. YAML indentation errors under templateContext: are surfaced as “mapping values are not allowed here” or “could not find expected ‘:’” errors pointing at the wrong line.
Solution: Validate indentation with a YAML linter before committing. The correct structure:
- deployment: DeployDev # 2 spaces (list item under jobs:)
templateContext: # 2 spaces (sibling of strategy:, pool:)
environment: dev # 4 spaces (child of templateContext:)
serviceConnection: sc-dev # 4 spaces — same level as environment
strategy: # 2 spaces (sibling of templateContext:)
runOnce:
Key Takeaways
templateContextis a compile-time escape hatch for attaching custom metadata tojobList,deploymentList, andstageListentries. Azure DevOps recognizes it as a reserved property, bypassing schema validation without triggering a parse error.Access scope is strictly compile-time:
${{ deploy.templateContext.prop }}works inside a${{ each }}loop in a template file; it does not work insidesteps:at runtime.The canonical bridge to runtime is the job-level
variables:injection pattern — extracttemplateContextvalues at compile time into pipeline variables, then consume them via$(VARIABLE_NAME)in scripts.templateContextis for per-item variation; a globalobjectparameter is for template-wide configuration. Combining both produces a template interface that stays stable as new environments or jobs are added.templateContextis stripped from the Expanded YAML completely. To debug what atemplateContextproperty resolved to, add a diagnostic script step that echoes the injected variable value:script: echo "$(TARGET_ENV)".
Next steps:
- Read the article on Escaping Parameter Hell in this series for how
templateContextfits into a larger template architecture alongside Config Object and Facade patterns. - Read the article on Advanced
${{ each }}Looping for techniques that combine${{ each }}withtemplateContextto generate dynamic stage matrices from complex nested objects. - Apply the variable injection bridge pattern to any existing template that uses
templateContextvalues inside scripts — the bridge is the only safe access method.
