Table of contents
- Azure Pipelines for .NET — Complete Guide
- Prerequisites
- Step 1: Set Up Azure DevOps
- Step 2: Connect Your Repository
- Step 3: Create azure-pipelines.yml
- Step 4: Key Concepts
- Step 5: Run Your Pipeline
- Step 6: Triggers
- Step 7: Variables and Secrets
- Understanding Jobs
- Understanding Stages
- Full Examples
- Free Tier Limits
- Troubleshooting
- Conclusion
- Property Reference
Azure Pipelines for .NET — Complete Guide
Setting up CI/CD manually is slow, error-prone, and hard to scale. Every time you push code, someone has to remember to build, test, and deploy it — and eventually someone forgets. Azure Pipelines solves this by automating the entire process through a single azure-pipelines.yml file that lives in your repository.
This guide walks you through everything from creating your Azure DevOps organization to deploying a .NET app across multiple environments with approval gates — all using a free Azure account.
Prerequisites
Before you start, make sure you have:
- A free Azure account → portal.azure.com
- A GitHub or Azure Repos repository containing a .NET project (
.csprojor.sln) - Basic familiarity with Git (commit, push, branch)
- .NET SDK 10 installed locally for testing your project before pushing
Step 1: Set Up Azure DevOps
- Go to dev.azure.com and sign in with your Azure account
- Click New organization and follow the prompts
- Create a new project — give it a name such as
my-dotnet-pipeline, set visibility to Private, and click Create
Important: New Azure DevOps accounts do not automatically receive a free Microsoft-hosted parallel job — this applies to both private and public projects. You must request it manually. This is completely free, no credit card or billing required. You can verify your current status at
Organization Settings → Pipelines → Parallel jobs— if Microsoft-hosted shows 0, you need to request the grant. Fill in the form at aka.ms/azpipelines-parallelism-request. Approval typically takes 2–3 business days.
Step 2: Connect Your Repository
- Inside your project, navigate to Pipelines → Create Pipeline
- Select where your code lives — GitHub, Azure Repos Git, or Bitbucket Cloud
- Choose your repository from the list
- Authorize Azure DevOps access (an OAuth popup appears for GitHub)
- Azure will suggest a starter template — you can dismiss it and write your own YAML instead
Step 3: Create azure-pipelines.yml
Create a file named azure-pipelines.yml in the root of your repository. This file is the single source of truth for your pipeline.
Here is the minimal working example to get started:
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
dotnetVersion: '10.x'
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
displayName: 'Install .NET SDK'
- script: dotnet --version
displayName: 'Print .NET Version'
- script: dotnet build
displayName: 'Build Solution'
Commit and push this file to your repository — the pipeline will trigger automatically on the next push to main.
Step 4: Key Concepts
Before writing more complex pipelines, it helps to understand the building blocks:
| Concept | What It Does |
|---|---|
| trigger | Defines which branch pushes automatically start the pipeline. Without a trigger, the pipeline only runs manually. |
| pool | Defines the machine (agent) that runs the pipeline. Microsoft provides hosted agents so you don't need to manage your own server. |
| vmImage | The operating system of the hosted agent. ubuntu-latest is the most common choice — it's fast, free, and supports .NET fully. |
| stages | Top-level phases that group related work: Build, Test, Deploy. Each stage must fully succeed before the next one starts, giving you a clear promotion flow. |
| jobs | A unit of work inside a stage that runs on its own agent. Multiple jobs in the same stage run in parallel, which is useful for testing on multiple platforms simultaneously. |
| steps | The individual commands or tasks inside a job. Steps run sequentially, one after another, on the same agent. |
| variables | Named values you define once and reuse across the pipeline. Keeps your YAML clean and makes it easy to change things like the .NET version or build configuration in one place. |
| task | A pre-built, reusable action from Microsoft or the marketplace. For example, DotNetCoreCLI@2 wraps the dotnet CLI so you don't have to write raw scripts for every command. |
| artifact | A file or folder produced by one stage and passed to another. For example, the Build stage compiles and publishes the app, uploads it as an artifact, and the Deploy stage downloads and deploys it — without rebuilding from scratch. |
| dependsOn | Controls execution order. By default stages run sequentially, but you can use dependsOn to run things in parallel or create a custom dependency graph. |
| condition | An expression that decides whether a stage, job, or step should run at all. For example, only deploy when the branch is main and all previous stages succeeded. |
| environment | A named deployment target (dev, staging, production) tracked in Azure DevOps. Environments can have approval gates — a human must approve before the pipeline continues to that environment. |
Step 5: Run Your Pipeline
- Push
azure-pipelines.ymlto your repository - Go to Azure DevOps → Pipelines — your pipeline will appear in the list
- Click Run pipeline or let it trigger automatically from your push
- Click into the run to see live logs for each step
- A green checkmark means success ✅ — a red X means failure ❌ (click the step to see the full error log)
Step 6: Triggers
Triggers control exactly when your pipeline runs. Here are the most common patterns:
# Run on specific branches only
trigger:
branches:
include:
- main
- develop
exclude:
- feature/*
# Run on Pull Requests — acts as a CI gate before merging
pr:
branches:
include:
- main
# Scheduled nightly build at 2:00 AM UTC
schedules:
- cron: "0 2 * * *"
displayName: Nightly Build
branches:
include:
- main
always: true # Run even when there are no new commits
# Disable automatic triggering — manual runs only
trigger: none
Step 7: Variables and Secrets
Defining Variables
variables:
dotnetVersion: '10.x'
buildConfiguration: 'Release'
projectName: 'MyApp'
steps:
- script: echo "Building $(projectName) in $(buildConfiguration) mode"
displayName: 'Print Variables'
Storing Secrets Safely
Never hardcode passwords, tokens, or connection strings in your YAML file. Instead:
- Go to Pipelines → Edit → Variables (top-right corner)
- Click + Add, enter a name such as
CONNECTION_STRING, and paste the value - Check Keep this value secret 🔒 and click Save
Reference it in your pipeline like this:
steps:
- script: echo "Running migration..."
displayName: 'Run DB Migration'
env:
CONNECTION_STRING: $(CONNECTION_STRING)
Sharing Variables Across Pipelines
variables:
- group: dotnet-shared-vars # Created under Library → Variable Groups
- name: buildConfiguration
value: 'Release'
Understanding Jobs
In Azure Pipelines, a job is a unit of work that runs on an agent. To understand why jobs exist, it helps to see the full hierarchy:
Pipeline
└── Stage
└── Job ← runs on ONE agent
└── Steps (tasks/scripts)
The main reason jobs exist is parallelism and isolation. Each job runs on its own agent — its own clean machine or container — independently from other jobs, and can run in parallel with them.
A practical example: say you want to test your .NET app on multiple platforms at the same time:
jobs:
- job: TestOnLinux
pool:
vmImage: 'ubuntu-latest'
steps:
- script: dotnet test
- job: TestOnWindows
pool:
vmImage: 'windows-latest'
steps:
- script: dotnet test
Both jobs run simultaneously, cutting your pipeline duration in half. You couldn't do that with steps alone, because steps run sequentially on the same agent.
Other reasons to split work into multiple jobs:
- Different environments — one job builds, another deploys to a different machine
- Isolation — if one job fails, others can still run independently
- Dependencies — use
dependsOnto say "run this job only after that job succeeds"
In short: if you only had steps, everything would run sequentially on one machine. Jobs give you parallelism and flexibility.
Understanding Stages
Same idea as jobs, but at a higher level. Jobs give you parallelism — stages give you control over the flow.
Pipeline
└── Stage: Build ← must finish first
└── Job
└── Stage: Test ← runs after Build
└── Job
└── Stage: Deploy ← runs after Test, only on main
└── Job
The key things stages give you that jobs alone can't:
- Sequential gates — you don't want to deploy if the build failed. Stages enforce that order explicitly
- Approval gates — you can pause the pipeline between stages and require a human to approve before continuing (e.g. before deploying to production)
- Environment promotion — DEV → STAGING → PROD is a natural stage flow, each with different conditions and approvals
- Visibility — in the Azure DevOps UI, stages appear as separate visual blocks with their own status, making it easy to see exactly where a pipeline is or where it failed
- Scoped conditions — you can say "only run the Deploy stage if we're on the
mainbranch" without affecting the Build or Test stages
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: dotnet build
- stage: Test
dependsOn: Build # Only runs if Build succeeds
jobs:
- job: TestJob
steps:
- script: dotnet test
- stage: Deploy
dependsOn: Test
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployJob
environment: 'production' # Can require manual approval here
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to production..."
In short — if you only had jobs, you'd have parallelism but no clean way to model "this group of work must fully succeed before that group starts." Stages are the mechanism for that.
Full Examples
Example 1: Simple Build
A clean restore → build pipeline — a good starting point for any .NET project.
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
dotnetVersion: '10.x'
buildConfiguration: 'Release'
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
- task: DotNetCoreCLI@2
displayName: 'Restore NuGet Packages'
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: 'Build Solution'
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
- script: |
echo "Build ID: $(Build.BuildId)"
echo "Branch: $(Build.SourceBranchName)"
echo "Config: $(buildConfiguration)"
displayName: 'Print Build Info'
Example 2: Web API (Build + Test + Publish)
A three-stage pipeline suited for an ASP.NET Core Web API. It builds the app, runs unit tests with code coverage, and publishes the artifact — deploying only on pushes to main.
trigger:
- main
pr:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
dotnetVersion: '10.x'
buildConfiguration: 'Release'
artifactName: 'drop'
stages:
- stage: Build
displayName: '🔨 Build'
jobs:
- job: BuildJob
displayName: 'Restore, Build & Publish'
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
- task: DotNetCoreCLI@2
displayName: 'Restore'
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: 'Publish Web API'
inputs:
command: 'publish'
publishWebProjects: true
arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'
zipAfterPublish: true
- publish: $(Build.ArtifactStagingDirectory)
artifact: $(artifactName)
displayName: 'Upload Build Artifact'
- stage: Test
displayName: '🧪 Test'
dependsOn: Build
jobs:
- job: UnitTests
displayName: 'Run Unit Tests'
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
- task: DotNetCoreCLI@2
displayName: 'Run Tests with Coverage'
inputs:
command: 'test'
projects: '**/*Tests/*.csproj'
arguments: >
--configuration $(buildConfiguration)
--collect:"XPlat Code Coverage"
--logger trx
--results-directory $(Agent.TempDirectory)
- task: PublishTestResults@2
displayName: 'Publish Test Results'
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
searchFolder: $(Agent.TempDirectory)
condition: always()
- task: PublishCodeCoverageResults@2
displayName: 'Publish Code Coverage'
inputs:
summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'
condition: always()
- stage: Deploy
displayName: '🚀 Deploy'
dependsOn: Test
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployJob
displayName: 'Deploy to Production'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: $(artifactName)
displayName: 'Download Artifact'
- script: |
echo "Deploying build $(Build.BuildId)..."
ls $(Pipeline.Workspace)/$(artifactName)
displayName: 'Deploy Web API'
Example 3: NuGet Package
Builds a class library, packs it as a NuGet package with a version tied to the build ID, and pushes it to an Azure Artifacts feed.
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
dotnetVersion: '10.x'
buildConfiguration: 'Release'
majorMinorVersion: '1.0'
nugetVersion: '$(majorMinorVersion).$(Build.BuildId)'
stages:
- stage: Build
displayName: '🔨 Build & Pack'
jobs:
- job: PackJob
displayName: 'Build and Create NuGet Package'
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
- task: DotNetCoreCLI@2
displayName: 'Restore'
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: 'Run Tests'
inputs:
command: 'test'
projects: '**/*Tests/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-build'
- task: DotNetCoreCLI@2
displayName: 'Pack NuGet Package'
inputs:
command: 'pack'
packagesToPack: '**/*.csproj'
nobuild: true
versioningScheme: 'byEnvVar'
versionEnvVar: 'nugetVersion'
- publish: $(Build.ArtifactStagingDirectory)
artifact: 'nuget-packages'
displayName: 'Upload NuGet Packages'
- stage: Publish
displayName: '📦 Publish to Azure Artifacts'
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- job: PublishJob
steps:
- download: current
artifact: 'nuget-packages'
- task: DotNetCoreCLI@2
displayName: 'Push to Internal Feed'
inputs:
command: 'push'
packagesToPush: '$(Pipeline.Workspace)/nuget-packages/*.nupkg'
nuGetFeedType: 'internal'
publishVstsFeed: 'my-feed' # Replace with your feed name
Example 4: Docker Build and Push
Builds and tests the .NET app first, then packages it into a Docker image and pushes it to Azure Container Registry.
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
dotnetVersion: '10.x'
buildConfiguration: 'Release'
dockerRegistry: 'myregistry.azurecr.io'
imageName: 'my-dotnet-app'
imageTag: '$(Build.BuildId)'
stages:
- stage: Build
displayName: '🔨 Build & Test'
jobs:
- job: BuildJob
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
- task: DotNetCoreCLI@2
displayName: 'Restore'
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: 'Test'
inputs:
command: 'test'
projects: '**/*Tests/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-build'
- stage: Docker
displayName: '🐳 Docker Build & Push'
dependsOn: Build
jobs:
- job: DockerJob
steps:
- task: Docker@2
displayName: 'Login to ACR'
inputs:
command: 'login'
containerRegistry: 'my-acr-service-connection'
- task: Docker@2
displayName: 'Build Image'
inputs:
command: 'build'
repository: '$(dockerRegistry)/$(imageName)'
dockerfile: '**/Dockerfile'
buildContext: '.'
tags: |
$(imageTag)
latest
- task: Docker@2
displayName: 'Push to ACR'
inputs:
command: 'push'
repository: '$(dockerRegistry)/$(imageName)'
tags: |
$(imageTag)
latest
Place this Dockerfile in your project root alongside the .csproj:
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["MyApp/MyApp.csproj", "MyApp/"]
RUN dotnet restore "MyApp/MyApp.csproj"
COPY . .
WORKDIR "/src/MyApp"
RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
Example 5: Multi-Environment with Approvals
A production-grade pipeline that promotes through DEV → STAGING → PRODUCTION. DEV deploys automatically, and production requires a manual approval before it runs.
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
dotnetVersion: '10.x'
buildConfiguration: 'Release'
artifactName: 'webapp'
stages:
- stage: Build
displayName: '🔨 Build'
jobs:
- job: BuildJob
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
- task: DotNetCoreCLI@2
displayName: 'Restore'
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: 'Test'
inputs:
command: 'test'
projects: '**/*Tests/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-build'
- task: DotNetCoreCLI@2
displayName: 'Publish'
inputs:
command: 'publish'
publishWebProjects: true
arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'
zipAfterPublish: true
- publish: $(Build.ArtifactStagingDirectory)
artifact: $(artifactName)
- stage: DeployDev
displayName: '🟡 Deploy to DEV'
dependsOn: Build
jobs:
- deployment: DevDeploy
environment: 'dev' # No approval — deploys automatically
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: $(artifactName)
- task: AzureWebApp@1
displayName: 'Deploy to Dev App Service'
inputs:
azureSubscription: 'my-azure-service-connection'
appType: 'webApp'
appName: 'my-dotnet-app-dev'
package: '$(Pipeline.Workspace)/$(artifactName)/**/*.zip'
- stage: DeployStaging
displayName: '🟠 Deploy to STAGING'
dependsOn: DeployDev
jobs:
- deployment: StagingDeploy
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: $(artifactName)
- task: AzureWebApp@1
displayName: 'Deploy to Staging App Service'
inputs:
azureSubscription: 'my-azure-service-connection'
appType: 'webApp'
appName: 'my-dotnet-app-staging'
package: '$(Pipeline.Workspace)/$(artifactName)/**/*.zip'
- stage: DeployProd
displayName: '🟢 Deploy to PRODUCTION'
dependsOn: DeployStaging
jobs:
- deployment: ProdDeploy
environment: 'production' # Requires manual approval before this stage runs
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: $(artifactName)
- task: AzureWebApp@1
displayName: 'Deploy to Production App Service'
inputs:
azureSubscription: 'my-azure-service-connection'
appType: 'webApp'
appName: 'my-dotnet-app-prod'
package: '$(Pipeline.Workspace)/$(artifactName)/**/*.zip'
To configure the approval gate: go to Pipelines → Environments → production → Approvals and checks → Add → Approvals, then specify who must approve each production deployment.
Free Tier Limits
A typical .NET pipeline run takes 3–6 minutes, so the free 1,800 minutes per month is more than sufficient for learning and side projects.
| Resource | Free Amount |
|---|---|
| Microsoft-hosted agent minutes | 1,800 min/month (~30 hrs) |
| Parallel jobs | 1 (must request free grant) |
| Azure Repos storage | Unlimited (up to 5 users) |
| Artifacts storage | 2 GB |
| Pipeline runs | Unlimited (within minute quota) |
One-time action required: Microsoft does not grant a free Microsoft-hosted parallel job automatically — this applies to both private and public projects. Check your status at
Organization Settings → Pipelines → Parallel jobs. If Microsoft-hosted shows 0, fill in the free request form at aka.ms/azpipelines-parallelism-request right after creating your organization. No credit card or payment needed.
Troubleshooting
Pipeline not triggering after a push?
Make sure azure-pipelines.yml is in the root of the repository and the branch name in trigger: matches your actual branch (e.g. main vs master).
"No hosted parallelism has been purchased or granted" This means the free parallel job grant has not been approved yet. Submit the form at aka.ms/azpipelines-parallelism-request and wait for approval.
dotnet restore fails with a feed authentication error
Your project likely references a private NuGet feed. Store the feed credentials in a Variable Group under Library and expose them to the restore step via env:.
Tests are not discovered
Check that your test project paths match the glob pattern **/*Tests/*.csproj. Adjust the pattern to match your naming convention, for example **/*.Tests.csproj.
Artifact not found in a later stage
Ensure the Build stage has a publish: step and subsequent stages use a download: step with the matching artifact name.
Conclusion
Azure Pipelines gives .NET developers a powerful, free CI/CD platform that integrates directly with GitHub or Azure Repos. Starting from a minimal azure-pipelines.yml, you can progressively add stages for testing, packaging, containerization, and multi-environment deployments with approval gates — all in a single YAML file that lives alongside your code.
The five examples in this guide cover the most common real-world scenarios: a basic build, a Web API with test coverage, NuGet packaging, Docker container publishing, and a full DEV → STAGING → PROD promotion flow. Use them as starting points and adapt them to your project structure as your pipeline matures.
Property Reference
A complete reference of all azure-pipelines.yml properties with descriptions.
Root Properties
Top-level properties of the azure-pipelines.yml file:
| Property | Type | Required | Description |
|---|---|---|---|
| trigger | string / object | No | Defines which branch pushes start the pipeline automatically |
| pr | string / object | No | Starts the pipeline when a Pull Request is created or updated |
| schedules | list | No | Scheduled automatic runs using cron syntax (UTC) |
| pool | object | No (if set per stage) | Default agent for all jobs in the pipeline |
| variables | object / list | No | Global variables available across all stages and jobs |
| stages | list | No (or jobs) | List of pipeline stages. If omitted, jobs are used directly |
| jobs | list | No (or stages) | List of jobs without stage grouping |
| steps | list | No (or jobs) | List of steps without job grouping — simplest pipeline structure |
| name | string | No | Display name for the pipeline run (supports variables) |
| appendCommitMessageToRunName | boolean | No | Whether to append the commit message to the run name |
trigger
Controls automatic pipeline runs when code is pushed to the repository:
| Property | Type | Required | Description |
|---|---|---|---|
| trigger: none | string | — | Disables automatic triggering. Pipeline runs manually only |
| trigger: - main | string (shorthand) | — | Trigger on push to the specified branch |
| branches.include | list | No | Branches that trigger the pipeline |
| branches.exclude | list | No | Branches that do not trigger the pipeline |
| paths.include | list | No | Trigger only when files in these paths are changed |
| paths.exclude | list | No | Do not trigger if only files in these paths are changed |
| tags.include | list | No | Trigger on push of specified git tags |
| batch | boolean | No | If true — wait for the current run to finish before starting a new one |
# Example
trigger:
branches:
include:
- main
- develop
exclude:
- feature/*
paths:
include:
- src/
batch: true
pr
Runs the pipeline as a CI check when a Pull Request is created or updated:
| Property | Type | Required | Description |
|---|---|---|---|
| pr: none | string | — | Disables the Pull Request trigger |
| pr: - main | string (shorthand) | — | Run PR validation for the specified target branch |
| branches.include | list | No | Target branches of PRs that trigger the pipeline |
| branches.exclude | list | No | Target branches of PRs that do not trigger the pipeline |
| paths.include | list | No | Trigger only if the PR touches these paths |
| paths.exclude | list | No | Do not trigger if the PR only touches these paths |
| autoCancel | boolean | No | Cancel the previous run when a new commit is pushed to the PR (default: true) |
| drafts | boolean | No | Whether to run for draft PRs (default: true) |
# Example
pr:
branches:
include:
- main
paths:
include:
- src/
autoCancel: true
drafts: false
schedules
Automatic scheduled runs using cron syntax (UTC):
| Property | Type | Required | Description |
|---|---|---|---|
| cron | string | Yes | Schedule in cron format (UTC). Example: '0 2 * * *' — every day at 02:00 |
| displayName | string | No | Human-readable name of the schedule shown in the UI |
| branches.include | list | Yes | Branches this schedule applies to |
| branches.exclude | list | No | Branches excluded from this schedule |
| always | boolean | No | If true — run even when there are no new commits (default: false) |
| batch | boolean | No | If true — do not start a new run while one is already running |
# Example — every day at 02:00 UTC
schedules:
- cron: "0 2 * * *"
displayName: Nightly Build
branches:
include:
- main
always: true
pool
Defines the agent the pipeline or an individual job runs on:
| Property | Type | Required | Description |
|---|---|---|---|
| vmImage | string | Yes (for Microsoft-hosted) | Agent image: ubuntu-latest, windows-latest, macOS-latest |
| name | string | Yes (for self-hosted) | Name of the self-hosted agent pool |
| demands | list | No | Capability requirements for the self-hosted agent (e.g. specific software) |
| workspace.clean | string | No | Clean the working directory before running: outputs, resources, or all |
# Microsoft-hosted agent
pool:
vmImage: 'ubuntu-latest'
# Self-hosted agent
pool:
name: 'MyAgentPool'
demands:
- dotnet
variables
Defines variables accessible via $(name):
| Property | Type | Required | Description |
|---|---|---|---|
| name + value | string | Yes | A plain variable. Reference it with $(name) |
| group | string | No | Link a Variable Group from the Library |
| template | string | No | Import variables from an external YAML template |
| readonly | boolean | No | If true — the variable cannot be overridden at queue time |
| Secret variables | — | — | Defined via the pipeline UI or Variable Group. Automatically masked in logs |
# Example
variables:
- group: my-variable-group
- name: buildConfiguration
value: 'Release'
- name: dotnetVersion
value: '10.x'
stages
Top-level organizational units of the pipeline:
| Property | Type | Required | Description |
|---|---|---|---|
| stage | string | Yes | Unique identifier for the stage |
| displayName | string | No | Human-readable name shown in the UI |
| dependsOn | string / list | No | Which stages this stage depends on. If empty — runs in parallel |
| condition | string | No | Condition to run this stage. Default: succeeded() |
| variables | object / list | No | Variables scoped to this stage only |
| pool | object | No | Override the agent for all jobs in this stage |
| jobs | list | Yes | List of jobs that run in this stage |
| lockBehavior | string | No | Behavior when an exclusive lock is held: sequential or runLatest |
# Example
stages:
- stage: Build
displayName: '🔨 Build'
jobs:
- job: BuildJob
steps:
- script: dotnet build
- stage: Deploy
displayName: '🚀 Deploy'
dependsOn: Build
condition: succeeded()
jobs:
- job: DeployJob
steps:
- script: echo "Deploying..."
jobs
A unit of work inside a stage. Multiple jobs can run in parallel:
| Property | Type | Required | Description |
|---|---|---|---|
| job | string | Yes | Unique identifier for the job |
| displayName | string | No | Human-readable name shown in the UI |
| dependsOn | string / list | No | Which jobs this job depends on within the stage |
| condition | string | No | Condition to run this job |
| pool | object | No | Override the agent for this specific job |
| variables | object / list | No | Variables scoped to this job only |
| steps | list | Yes | List of steps that run in this job |
| timeoutInMinutes | number | No | Maximum runtime for the job in minutes (default: 60) |
| cancelTimeoutInMinutes | number | No | Time to wait after cancellation before forcefully terminating |
| continueOnError | boolean | No | If true — the pipeline continues even if this job fails |
| strategy.matrix | object | No | Run the job multiple times with different variable combinations |
| strategy.parallel | number | No | Number of parallel copies of this job to run |
| services | object | No | Sidecar service containers (e.g. a database) running alongside the job |
# Example with matrix strategy
jobs:
- job: Test
strategy:
matrix:
dotnet10:
dotnetVersion: '10.x'
dotnet8:
dotnetVersion: '8.x'
steps:
- task: UseDotNet@2
inputs:
versionSpec: $(dotnetVersion)
- script: dotnet test
steps
Properties shared by all step types (script, task, bash, etc.):
| Property | Type | Required | Description |
|---|---|---|---|
| displayName | string | No | Step name shown in logs and the UI |
| name | string | No | Step identifier used to reference its output in later steps |
| condition | string | No | Condition to run this step. Default: succeeded() |
| continueOnError | boolean | No | Continue the job even if this step fails |
| enabled | boolean | No | Disable a step without removing it (default: true) |
| timeoutInMinutes | number | No | Maximum runtime for this step in minutes |
| retryCountOnTaskFailure | number | No | Number of retry attempts on failure (task steps only) |
| env | object | No | Environment variables scoped to this step only. The only safe way to pass a secret into a script |
# Example
steps:
- script: dotnet test
displayName: 'Run Tests'
condition: succeeded()
continueOnError: false
timeoutInMinutes: 10
retryCountOnTaskFailure: 2
env:
MY_SECRET: $(MY_SECRET)
task
Built-in and marketplace tasks available in Azure DevOps:
| Property | Type | Required | Description |
|---|---|---|---|
| task | string | Yes | Task name and version. Example: DotNetCoreCLI@2, UseDotNet@2 |
| inputs | object | Depends on task | Parameters specific to each task |
| inputs.command | string | Depends on task | Command for DotNetCoreCLI@2: restore, build, test, publish, pack, push |
| inputs.projects | string / glob | No | Glob path to .csproj files. Example: **/*.csproj |
| inputs.arguments | string | No | Additional command-line arguments passed to the command |
| inputs.publishWebProjects | boolean | No | For publish command — automatically detect web projects |
| inputs.zipAfterPublish | boolean | No | Zip the publish output into an archive |
| inputs.versionSpec | string | For UseDotNet@2 | .NET SDK version to install. Example: 10.x |
| inputs.packageType | string | For UseDotNet@2 | Package type to install: sdk or runtime |
# Most common .NET tasks
- task: UseDotNet@2
inputs:
packageType: 'sdk'
versionSpec: '10.x'
- task: DotNetCoreCLI@2
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration Release'
script / bash / pwsh
Run shell commands directly as a step:
| Step type | Shell | Description |
|---|---|---|
| script | bash (Linux/macOS) / cmd (Windows) | Runs a single or multi-line shell command. Most common step type |
| bash | bash (any OS) | Forces bash even on a Windows agent |
| pwsh | PowerShell Core (cross-platform) | PowerShell 7+ on any OS |
| powershell | Windows PowerShell | Classic PowerShell on Windows agents only |
# Examples
- script: echo "Hello from cmd/bash"
displayName: 'script step'
- bash: |
echo "Always bash"
dotnet --version
displayName: 'bash step'
- pwsh: |
Write-Host "PowerShell Core"
displayName: 'pwsh step'
publish / download
Pass files between stages using pipeline artifacts:
| Property | Type | Required | Description |
|---|---|---|---|
| publish (step) | string | Yes | Path to the file or folder to upload as a pipeline artifact |
| artifact (in publish) | string | No | Name of the artifact. Default: drop |
| download (step) | string | Yes | Where to download from: current (this run) or a resource name |
| artifact (in download) | string | No | Name of the artifact to download. If omitted — all artifacts are downloaded |
| patterns | list | No | Glob filter applied when downloading artifact files |
| $(Build.ArtifactStagingDirectory) | variable | — | Temporary staging folder for preparing artifacts before publishing |
| $(Pipeline.Workspace) | variable | — | Base folder where artifacts are downloaded in subsequent stages |
# Publish artifact
- task: DotNetCoreCLI@2
inputs:
command: 'publish'
arguments: '--output $(Build.ArtifactStagingDirectory)'
- publish: $(Build.ArtifactStagingDirectory)
artifact: drop
# Download in a later stage
- download: current
artifact: drop
- script: ls $(Pipeline.Workspace)/drop
deployment
A special job type for deployments with environment support and approval gates:
| Property | Type | Required | Description |
|---|---|---|---|
| deployment | string | Yes | Unique identifier for the deployment job |
| displayName | string | No | Human-readable name shown in the UI |
| environment | string | Yes | Target environment name (dev, staging, production). Created automatically on first run |
| strategy.runOnce | object | Yes (one of strategies) | Simplest strategy — deploy once |
| strategy.rolling | object | No | Gradually deploy across groups of servers |
| strategy.canary | object | No | Deploy to a subset of servers first to validate before full rollout |
| strategy.runOnce.deploy.steps | list | Yes | The deployment steps |
| strategy.runOnce.preDeploy.steps | list | No | Steps that run before deployment |
| strategy.runOnce.postDeploy.steps | list | No | Steps that run after a successful deployment |
| strategy.runOnce.on.failure.steps | list | No | Steps that run when the deployment fails |
# Example
- deployment: DeployProd
displayName: 'Deploy to PROD'
environment: 'production'
strategy:
runOnce:
preDeploy:
steps:
- script: echo "Pre-deployment check"
deploy:
steps:
- download: current
artifact: drop
- task: AzureWebApp@1
inputs:
azureSubscription: 'my-connection'
appName: 'my-app-prod'
package: '$(Pipeline.Workspace)/drop/**/*.zip'
postDeploy:
steps:
- script: echo "Deployment successful"
on:
failure:
steps:
- script: echo "Deployment failed!"
condition
Expressions for conditional execution of steps, jobs, and stages:
| Expression | Description |
|---|---|
| succeeded() | Run if all previous steps/jobs/stages completed successfully (default) |
| failed() | Run only if the previous step or job failed |
| always() | Always run, regardless of the result of previous steps |
| succeededOrFailed() | Run on success or failure, but not on cancellation |
| canceled() | Run only if the pipeline was canceled |
| eq(variables['Build.SourceBranch'], 'refs/heads/main') | Run only for the main branch |
| and(succeeded(), eq(variables['Build.Reason'], 'PullRequest')) | Combine multiple conditions using and() and or() |
| ne(variables['Build.Reason'], 'Schedule') | Run only if the trigger was not a scheduled run |
# Usage examples
- script: echo "Main branch only"
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
- script: echo "Always, even on failure"
condition: always()
- stage: Deploy
condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
dependsOn
Controls the execution order of stages and jobs:
| Syntax | Description |
|---|---|
| dependsOn: StageA | Depends on a single stage or job |
| dependsOn: [StageA, StageB] | Depends on multiple — runs after all of them complete |
| dependsOn: [] | Empty list — runs in parallel with others, no waiting |
| (omitted) | Runs sequentially after the previous stage in the list (default behavior) |
# Examples
stages:
- stage: A
- stage: B
dependsOn: A # After A
- stage: C
dependsOn: [] # Parallel with A and B
- stage: D
dependsOn: [B, C] # After B and C
Built-in Variables
Variables automatically provided by Azure Pipelines in every run:
| Variable | Description |
|---|---|
| $(Build.BuildId) | Unique numeric identifier of the current run |
| $(Build.BuildNumber) | Human-readable run name (configurable via name:) |
| $(Build.SourceBranch) | Full branch name. Example: refs/heads/main |
| $(Build.SourceBranchName) | Short branch name. Example: main |
| $(Build.Reason) | Trigger reason: Manual, IndividualCI, PullRequest, Schedule, etc. |
| $(Build.Repository.Name) | Name of the repository |
| $(Build.SourcesDirectory) | Path to the cloned repository on the agent |
| $(Build.ArtifactStagingDirectory) | Temporary folder for staging artifacts before publishing |
| $(Pipeline.Workspace) | Base working folder for the pipeline on the agent |
| $(Agent.OS) | Agent operating system: Windows_NT, Linux, or Darwin |
| $(Agent.TempDirectory) | Temporary folder on the agent (cleared between jobs) |
| $(System.DefaultWorkingDirectory) | Default working directory (same as Build.SourcesDirectory) |
| $(System.AccessToken) | OAuth token for authenticating against the Azure DevOps REST API |
| $(Environment.Name) | Name of the environment in a deployment job |
