Azure DevOps Capabilities Summary
Table of Contents
- How Azure DevOps Pipelines Work
- Key Extensibility Capabilities
- How Security Works
- Most Important Limitations
- Appendix — NH-Shared Template Architecture
1. How Azure DevOps Pipelines Work
Pipeline Lifecycle
An Azure DevOps YAML pipeline goes through three distinct phases:
| Phase | What Happens |
|---|---|
| 1. Compile-time | The YAML is parsed. Template expressions (${{ }}) are evaluated, templates are expanded, ${{ if }} / ${{ each }} blocks are resolved. The result is a fully-expanded pipeline definition. Service connections and environments are validated at this stage. |
| 2. Plan-time | Stages, jobs, and their dependencies are organized into an execution plan. Conditions on stages and jobs are checked against the current context (branch, variables, etc.). |
| 3. Run-time | Jobs are dispatched to agents. Runtime expressions ($[ ]) and condition: expressions are evaluated. Tasks execute sequentially within each job. Variables can be set dynamically via logging commands (##vso[task.setvariable ...]). |
Critical distinction:
${{ }}expressions are evaluated before the pipeline runs.condition:and$[ ]expressions are evaluated during the run. This difference is the root cause of many “the pipeline is not valid” errors — a service connection referenced inside acondition:-guarded step is still validated at compile time.
Core Structure
trigger: # When the pipeline runs automatically
schedules: # Cron-based scheduled runs
resources: # External repos, pipelines, containers
variables: # Pipeline-scoped variables and variable groups
stages:
- stage: Build
jobs:
- job: BuildApp
pool:
vmImage: ubuntu-latest
steps:
- task: DotNetCoreCLI@2
inputs:
command: build
- stage: Deploy
dependsOn: Build
jobs:
- deployment: DeployWeb # Special job type for deployments
environment: Production # Links to an Environment for approvals
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1Key Concepts
| Concept | Description |
|---|---|
| Stage | A logical boundary in the pipeline (e.g., Build, Deploy, Quality). Stages run sequentially by default but can run in parallel if dependsOn is configured. |
| Job | A unit of work that runs on a single agent. Jobs within a stage run in parallel by default. |
| Deployment Job | A special job type (deployment:) that tracks deployment history, links to environments, and supports strategies (runOnce, rolling, canary). |
| Step | A single task or script within a job. Steps always run sequentially. |
| Task | A pre-built unit of automation (e.g., DotNetCoreCLI@2, AzureWebApp@1). Tasks are versioned. |
| Template | A reusable YAML fragment that can define stages, jobs, steps, or variables. Templates accept parameters. |
| Variable | A name-value pair. Can be set at pipeline, stage, job, or step level. Can be marked readonly. |
| Artifact | A file or package produced by one job/stage and consumed by another (via publish / download). |
| Resource | An external dependency — another repository, pipeline, container, or package feed. |
| Pool | The agent pool where jobs run. Can be Microsoft-hosted (vmImage: ubuntu-latest) or self-hosted (name: MyPool). |
| Demands | Agent capabilities required by a job (e.g., java, msbuild). Only agents meeting all demands are selected. |
Variable Scoping and Precedence
Variables are resolved in this order (later overrides earlier):
- Variable groups (linked at pipeline level)
- Pipeline-level
variables: - Stage-level
variables: - Job-level
variables: - Task
env:mappings - Runtime
##vso[task.setvariable]logging commands
Variables set via logging commands are scoped to the current job by default. Use isOutput=true to expose them to downstream jobs or stages:
# Setting an output variable
- pwsh: |
echo "##vso[task.setvariable variable=MyVar;isOutput=true]myValue"
name: SetVar
# Consuming in same stage, different job
- job: Consumer
variables:
myVar: $[dependencies.Producer.outputs['SetVar.MyVar']]
# Consuming in a different stage
- stage: NextStage
variables:
myVar: $[stageDependencies.PrevStage.Producer.outputs['SetVar.MyVar']]2. Key Extensibility Capabilities
2.1 Templates
Templates are the primary reuse mechanism. They can encapsulate stages, jobs, steps, or variables and accept typed parameters.
# Template definition (reusable)
parameters:
- name: environment
type: string
- name: skipTests
type: boolean
default: false
steps:
- task: DotNetCoreCLI@2
inputs:
command: build
- ${{ if not(parameters.skipTests) }}:
- task: DotNetCoreCLI@2
inputs:
command: test# Template consumption
steps:
- template: steps/dotnet.build.yml
parameters:
environment: production
skipTests: falseTemplate types:
| Type | Contains | Use Case |
|---|---|---|
| Steps template | A list of steps | Reusable build/test/deploy step sequences |
| Jobs template | A list of jobs | Reusable job definitions (e.g., build + publish) |
| Stages template | A list of stages | Reusable stage definitions (e.g., deploy + smoke test) |
| Variables template | A list of variables | Shared variable definitions |
2.2 extends Keyword
The extends keyword enforces that a pipeline must derive from an approved template. This is used for governance — an organization can require all pipelines to extend from a controlled template:
# Consumer pipeline
resources:
repositories:
- repository: shared
type: git
name: NH-Shared
ref: refs/heads/main
extends:
template: devops/pipelines/templates/appService.yml@shared
parameters:
mainProjectName: MyApi
deployedEnvironments:
- internalName: Prod
azureSubscription: MySubscription2.3 Template Expressions (${{ }})
Template expressions are evaluated at compile time and support:
| Expression | Example | Purpose |
|---|---|---|
| Conditionals | ${{ if eq(parameters.env, 'prod') }}: |
Include/exclude YAML blocks |
| Iteration | ${{ each item in parameters.list }}: |
Generate repeated blocks from a list |
| Object spread | ${{ insert }}: ${{ item }} |
Spread object properties into a mapping |
| Functions | ${{ or() }}, ${{ and() }}, ${{ not() }}, ${{ eq() }}, ${{ ne() }}, ${{ coalesce() }}, ${{ convertToJson() }} |
Logic and data transformation |
| String interpolation | value: ${{ parameters.name }}-suffix |
Build strings from parameters |
2.4 Dynamic Stage Generation with ${{ each }}
This is a powerful pattern for generating per-environment deployment stages from a single list:
# Top-level template
parameters:
- name: deployedEnvironments
type: object
stages:
- ${{ each env in parameters.deployedEnvironments }}:
- template: stages/deploy.yml
parameters:
${{ insert }}: ${{ env }} # Spread all properties from the object
isFunction: ${{ parameters.isFunction }} # Add extra paramsThe ${{ insert }} directive spreads the key-value pairs of the environment object directly into the parameters mapping, avoiding the need to explicitly list every property.
2.5 Cross-Repository Templates
Templates can be hosted in a separate repository and referenced via resources.repositories:
resources:
repositories:
- repository: shared # Alias
type: git # Azure Repos Git
name: NH-Shared # Repo name in the same project
ref: refs/heads/main # Branch/tag
extends:
template: devops/pipelines/templates/appService.yml@sharedThis enables a centralized template library that multiple pipelines across different repos can consume.
2.6 Parameter Types
| Type | Description |
|---|---|
string |
A single string value |
boolean |
true / false |
number |
Numeric value |
object |
Any YAML structure (maps, lists, nested objects) |
step |
A single step definition |
stepList |
A list of step definitions |
job |
A single job definition |
jobList |
A list of job definitions |
stage |
A single stage definition |
stageList |
A list of stage definitions |
2.7 Runtime Expressions and Conditions
Runtime expressions ($[ ] and condition:) are evaluated during pipeline execution:
# Runtime variable expression
variables:
isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]
# Step condition
- task: AzureWebApp@1
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))2.8 Artifacts and Data Flow Between Stages
# Publish in Build stage
- task: PublishPipelineArtifact@1
inputs:
artifact: dist-dotnet
targetPath: $(Build.ArtifactStagingDirectory)
# Download in Deploy stage
- download: current
artifact: dist-dotnetArtifacts are the only way to pass files between stages (since stages may run on different agents).
3. How Security Works
3.1 Service Connections
Service connections store credentials for external services (Azure subscriptions, SonarQube, Docker registries, etc.). They are:
- Created at the project or organization level in Azure DevOps
- Authorized per-pipeline (or for all pipelines)
- Referenced by name in YAML:
SonarQube: SonarQubeServiceConnection - Validated at compile time — if the connection doesn’t exist or isn’t authorized, the pipeline fails before any job runs
| Connection Type | Purpose | Example |
|---|---|---|
| Azure Resource Manager | Deploy to Azure subscriptions | App Service, AKS, Terraform |
| Kubernetes | Deploy to Kubernetes clusters | KubernetesManifest@1 |
| Docker Registry | Push/pull container images | ACR login, build, push |
| SonarQube | Code quality analysis | SonarQubePrepare@6 |
| Generic / Custom | Third-party tools | BlackDuck, JFrog |
3.2 Environments and Approvals
Environments provide deployment gates and tracking:
- deployment: Deploy
environment: Production # Must exist in Azure DevOps
strategy:
runOnce:
deploy:
steps: ...Environments can have:
- Approval checks — require manual approval before deployment
- Branch filters — only allow deployments from specific branches
- Business hours — restrict deployments to certain time windows
- Exclusive locks — prevent concurrent deployments
- Template checks — require the pipeline to extend from an approved template
3.3 Manual Validation
For human-in-the-loop approval within a pipeline:
- job: ManualValidation
pool: server # Runs on Azure DevOps, not an agent
steps:
- task: ManualValidation@0
inputs:
notifyUsers: approver@company.com
instructions: 'Please validate the deployment'
timeoutInMinutes: 1440 # 24 hours3.4 Variable Groups and Secrets
Variable groups centralize secrets and configuration:
variables:
- group: NH-SECURITY-VG # Links a variable group
- name: PublicVar
value: 'not-a-secret'- Secret variables are masked in logs and cannot be passed across stages (must use artifacts or
isOutput) - Azure Key Vault integration — variable groups can source secrets from Key Vault
3.5 Token and Credential Handling
| Pattern | Mechanism |
|---|---|
| System access token | $(System.AccessToken) — the pipeline’s OAuth token for Azure DevOps APIs and feed access |
| NuGet authentication | NuGetAuthenticate@1 task authenticates against Azure Artifacts feeds |
| Docker build secrets | --build-arg ACCESS_TOKEN=$(System.AccessToken) passed into Docker builds |
| Environment variables | Sensitive values mapped via env: blocks on steps |
3.6 Pipeline Security Controls
| Control | Description |
|---|---|
extends templates |
Force all pipelines to derive from approved templates (can be enforced via org policy) |
| Required template checks | Environments can mandate that pipelines use specific templates |
| Branch protection | Service connections and environments can be restricted to specific branches |
| Pipeline permissions | Service connections must be explicitly authorized for each pipeline |
| Readonly variables | readonly: true prevents overriding in downstream scopes |
| Audit logging | All pipeline runs, approvals, and resource access are logged |
3.7 Security Scanning Integration
Azure DevOps pipelines commonly integrate security scanning tools:
| Tool Category | Examples | Purpose |
|---|---|---|
| SAST (Static Analysis) | SonarQube, Microsoft Security DevOps | Code quality and vulnerability detection |
| SCA (Software Composition) | BlackDuck (Synopsys Detect), Mend Bolt | Dependency vulnerability scanning |
| Container Scanning | MetaDefender, JFrog VDOO | Container image security analysis |
| IaC Scanning | tfsec, Microsoft Security DevOps | Infrastructure-as-Code compliance |
4. Most Important Limitations
Execution & Platform Limits
4.0.1 Parallel Jobs and Concurrency
| Aspect | Free Tier (Private) | Free Tier (Public) | Paid |
|---|---|---|---|
| Microsoft-hosted parallel jobs | 1 | Up to 10 | Up to 25 per org (contact support for more) |
| Self-hosted parallel jobs | 1 | Unlimited | Unlimited (no charge per agent) |
| Monthly time limit | 1,800 min (30 hrs) | No limit | No limit |
| Max job duration | 60 min | 360 min (6 hrs) | 360 min (6 hrs) |
Key constraints:
- Free tier must be requested — new organizations don’t receive it automatically; submit a request at https://aka.ms/azpipelines-parallelism-request (can take several business days)
- Parallel jobs are org-wide — you cannot partition them to specific projects or agent pools. Two runs in Project A will block Project B if all parallel slots are consumed
- Each agent runs one job at a time — to run N concurrent jobs, you need N agents (or N parallel job slots for Microsoft-hosted)
- Rule of thumb: ~1 parallel job per 4–5 developers
- Server jobs (e.g.,
ManualValidation@0) do not consume parallel jobs
4.0.2 Job and Pipeline Timeouts
| Scope | Default | Maximum |
|---|---|---|
| Job timeout (Microsoft-hosted, private) | 60 min | 60 min (paid: 360 min) |
| Job timeout (Microsoft-hosted, public) | 360 min | 360 min |
| Job timeout (self-hosted) | 60 min | Unlimited (timeoutInMinutes: 0) |
| Server job timeout | 60 min | 30 days |
| Pipeline timeout | 360 min | 360 min (Microsoft-hosted) |
Cancel timeout (cancelTimeoutInMinutes) |
5 min | 35,790 min |
Setting
timeoutInMinuteshigher than the hosted agent maximum has no effect on Microsoft-hosted agents — the built-in limit always wins.
4.0.3 Microsoft-Hosted Agent Constraints
- Ephemeral — every job gets a fresh VM; no state persists between jobs
- No GPU agents available
- 10 GB disk space for artifacts and build outputs (approximate)
- Cannot RDP/SSH into the agent for debugging
- Available images:
ubuntu-latest,windows-latest,macos-latest(and specific versioned images) - Cold start latency: typically 20–40 seconds for agent provisioning before the first step runs
- Software updates: Microsoft updates images on their schedule — you cannot pin a specific image patch version indefinitely
4.0.4 Self-Hosted Agent Constraints
- Unlimited agents can be registered for free
- No per-agent charge — you pay only for parallel job slots
- Agent pools are org-scoped — pools can be shared across projects, but projects must be authorized
- Agent updates — agents auto-update by default; this can briefly take agents offline
- Scale set agents (VMSS) are available for auto-scaling, but require Azure infrastructure setup
4.0.5 Retention and Storage
| Setting | Default | Maximum |
|---|---|---|
| Run retention (days) | 30 days | Configurable per project |
| Artifact retention | Same as run retention | Configurable per project |
| Recent runs to keep | 3 per pipeline per branch | Configurable per project |
| Release retention | 30 days | Configurable (per-pipeline for classic) |
| Permanently destroy releases | 14 days after deletion | Configurable at project level |
| Test results retention | Configurable | Can be set to “Never delete” |
Key notes:
- Per-pipeline retention is deprecated for YAML pipelines — retention can only be configured at the project level
- YAML multistage pipelines cannot vary retention by deployment environment — unlike classic releases, you can’t keep production deployments longer than dev deployments through retention settings
- Retention is processed once per day; you cannot control the schedule
- Universal Packages, NuGet, npm are NOT governed by pipeline retention — they have their own feed-level retention
4.0.6 Rate Limits and API Throttling
- Azure DevOps enforces rate limits on REST API calls — excessive automation or nonoptimized queries can trigger throttling (HTTP 429)
- Throttled requests are delayed, not rejected, but sustained overuse can cause temporary blocks
- Pipeline-triggered REST calls (e.g., via scripts in steps) count toward rate limits
4.0.7 Organization and Project Limits
| Resource | Limit |
|---|---|
| Projects per organization | 1,000 |
| Pipelines per project | Unlimited |
| Self-hosted agents per org | Unlimited |
| Variable groups | No hard limit |
| Service connections | No hard limit |
| Environments per project | No hard limit |
4.0.8 Pricing (as of 2025)
| Item | Cost |
|---|---|
| Microsoft-hosted parallel job | ~$40/month per additional parallel job |
| Self-hosted parallel job | ~$15/month per additional parallel job |
| Free tier (private, Microsoft-hosted) | 1 parallel job, 1,800 min/month |
| Free tier (self-hosted) | 1 parallel job, unlimited minutes |
| Azure Artifacts storage | 2 GB free, then ~$2/GB/month |
Prices are indicative — check Azure DevOps pricing for current rates.
YAML Composition Limitations
4.1 Compile-Time Validation of Service Connections
This is the most impactful limitation.
Azure DevOps validates all service connection references at compile time, even if the step that uses them is guarded by a runtime condition:. If the service connection doesn’t exist in the project, the pipeline fails with a validation error before any job starts.
# THIS FAILS even if SkipSonarQube = true at runtime
- task: SonarQubePrepare@6
condition: and(succeeded(), ne(variables['SkipSonarQube'], 'true')) # Runtime!
inputs:
SonarQube: SonarQubeServiceConnection # Validated at compile time!Workaround: Use compile-time ${{ if }} expressions to exclude the entire step:
# THIS WORKS — the step is removed from the compiled pipeline
- ${{ if not(parameters.skipSonarQube) }}:
- task: SonarQubePrepare@6
inputs:
SonarQube: SonarQubeServiceConnection4.2 No Conditional Service Connection Names
You cannot use runtime variables or expressions to dynamically select a service connection. The connection name must be a compile-time constant (literal string or template expression).
# DOES NOT WORK — runtime variables not supported for service connections
SonarQube: $(MyConnectionVariable)
# WORKS — template parameter resolved at compile time
SonarQube: ${{ parameters.connectionName }}4.3 No Cross-Stage Variable Passing (Directly)
Variables set at runtime in one stage are not directly accessible in another stage. You must use one of:
- Output variables with the
stageDependenciessyntax - Pipeline artifacts for file-based data
- Variable groups or Key Vault for shared configuration
# Stage 1: Set output
- pwsh: echo "##vso[task.setvariable variable=Tag;isOutput=true]v1.0"
name: SetTag
# Stage 2: Consume (verbose syntax)
variables:
tag: $[stageDependencies.Build.BuildJob.outputs['SetTag.Tag']]4.4 Template Expression Limitations
- No string manipulation functions — no
split(),replace(),substring(),toLower()in${{ }} - No arithmetic — no
${{ add(1, 2) }}or similar - No access to runtime variables —
${{ variables.Build.SourceBranch }}doesn’t work;${{ }}can only see parameters and compile-time-defined variables - Limited debugging — no way to “print” template expression values during compilation
4.5 YAML Structure Constraints
- No anchors or aliases — YAML anchors (
&/*) are not supported - No custom YAML tags — only Azure DevOps-specific directives are recognized
${{ insert }}only works in mappings — you cannot insert into sequences- Template nesting depth — templates can reference other templates, but deep nesting increases compile time and makes debugging harder
4.6 Environment / Approval Limitations
- Environments are project-scoped — they cannot be shared across Azure DevOps projects
- No programmatic approval — approvals require human interaction through the UI or REST API
- Environment checks are “all must pass” — you cannot configure “any one of” approval logic natively
4.7 Agent and Pool Constraints
- Microsoft-hosted agents are ephemeral — no state persists between jobs. All dependencies must be installed fresh or restored from cache
- No GPU agents in Microsoft-hosted pools
- Job timeout defaults to 60 minutes (max 360 for Microsoft-hosted, unlimited for self-hosted)
- Pipeline timeout defaults to 360 minutes
- Parallel job limits — governed by your organization’s purchased parallel job count
4.8 Trigger and Scheduling Limitations
- Path filters are inclusive by default —
exclude:paths only work wheninclude:is also specified (or defaults to all paths) - Scheduled triggers don’t run on paused pipelines
- CI triggers on template repos don’t cascade — changing a template in a shared repo does not automatically trigger consumer pipelines (unless the consumer pipeline also monitors that repo)
- No webhook-based triggers in YAML — webhook triggers require classic pipeline definitions or service hooks with workarounds
4.9 Artifact and Data Limitations
- Pipeline artifacts are immutable — once published, they cannot be modified
- No artifact streaming — the entire artifact must be downloaded before use
- Artifact size limits — no hard limit, but very large artifacts slow down jobs significantly
- Log output limit — individual task logs truncate at ~16 MB; extremely verbose tasks may lose output