Deploying with Azure Pipelines

A deployment pipeline does more than run a script on a server. It tracks history, enforces approval gates, targets specific environments, and gives you a consistent path from code to production. This page covers how to write multi-stage Azure Pipelines deployments with environments, conditions, and deployment jobs.

Deployment jobs vs regular jobs

A standard pipeline job runs tasks in sequence on an agent. A deployment job is a special job type that records deployment history, integrates with Azure DevOps Environments, and supports built-in deployment strategies (runOnce, rolling, canary). Use deployment jobs for anything that changes a live environment.

# The key difference: a deployment job targets an environment
jobs:
  # Regular job — no deployment tracking
  - job: BuildJob
    steps:
      - script: dotnet build

  # Deployment job — tracked in Environments, supports approval gates
  - deployment: DeployDev
    environment: 'dev'   # must exist as an Azure DevOps Environment
    strategy:
      runOnce:
        deploy:
          steps:
            - script: echo "Deploying..."

Multi-stage pipeline: build → dev → staging → prod

The following pipeline builds an app, deploys to dev automatically, then waits for approval before deploying to staging and production. Each stage passes the build artifact forward rather than rebuilding.

# azure-pipelines.yml
trigger:
  branches:
    include:
      - main

variables:
  buildConfiguration: 'Release'
  webAppName: 'myapp'

pool:
  vmImage: 'ubuntu-latest'

stages:
  - stage: Build
    displayName: 'Build'
    jobs:
      - job: Build
        steps:
          - task: UseDotNet@2
            inputs:
              packageType: 'sdk'
              version: '8.x'

          - 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 --collect:"XPlat Code Coverage"'

          - task: DotNetCoreCLI@2
            displayName: 'Publish'
            inputs:
              command: 'publish'
              projects: '**/MyApp.csproj'
              arguments: '--configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)'
              zipAfterPublish: true

          - task: PublishBuildArtifacts@1
            inputs:
              pathToPublish: '$(Build.ArtifactStagingDirectory)'
              artifactName: 'drop'

  - stage: Deploy_Dev
    displayName: 'Deploy to Dev'
    dependsOn: Build
    condition: succeeded()
    jobs:
      - deployment: DeployDev
        environment: 'dev'   # no approval gates — auto-deploys
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: drop

                - task: AzureWebApp@1
                  displayName: 'Deploy to App Service (Dev)'
                  inputs:
                    azureSubscription: 'My Azure Subscription'
                    appType: 'webApp'
                    appName: '$(webAppName)-dev'
                    package: '$(Pipeline.Workspace)/drop/**/*.zip'
                    deploymentMethod: 'auto'

  - stage: Deploy_Staging
    displayName: 'Deploy to Staging'
    dependsOn: Deploy_Dev
    condition: succeeded()
    jobs:
      - deployment: DeployStaging
        environment: 'staging'   # has approval gate configured in Azure DevOps UI
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: drop

                - task: AzureWebApp@1
                  displayName: 'Deploy to App Service (Staging)'
                  inputs:
                    azureSubscription: 'My Azure Subscription'
                    appType: 'webApp'
                    appName: '$(webAppName)-staging'
                    package: '$(Pipeline.Workspace)/drop/**/*.zip'
                    deploymentMethod: 'zipDeploy'

  - stage: Deploy_Prod
    displayName: 'Deploy to Production'
    dependsOn: Deploy_Staging
    condition: succeeded()
    jobs:
      - deployment: DeployProd
        environment: 'prod'   # requires approval + business hours check
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: drop

                - task: AzureWebApp@1
                  displayName: 'Deploy to App Service (Prod)'
                  inputs:
                    azureSubscription: 'My Azure Subscription'
                    appType: 'webApp'
                    appName: '$(webAppName)-prod'
                    package: '$(Pipeline.Workspace)/drop/**/*.zip'
                    deploymentMethod: 'zipDeploy'

Controlling stage execution with conditions

The condition field on a stage or job controls when it runs. Azure Pipelines has a set of built-in condition functions. You can combine them with variable checks to create rules like “deploy to production only on main branch merges.”

# Only run this stage if the previous succeeded AND we're on main
- stage: Deploy_Prod
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))

# Run this stage even if a previous stage failed (for cleanup stages)
- stage: Cleanup
  condition: always()

# Run only if manually triggered
- stage: HotfixDeploy
  condition: eq(variables['Build.Reason'], 'Manual')

# Run if this specific stage succeeded (not just the immediate predecessor)
- stage: Notify
  dependsOn:
    - Deploy_Dev
    - Deploy_Staging
  condition: and(succeeded('Deploy_Dev'), succeeded('Deploy_Staging'))

Deploying to AKS with Kubernetes manifests

- stage: Deploy_AKS
  displayName: 'Deploy to AKS'
  jobs:
    - deployment: DeployAKS
      environment: 'aks-dev.default'   # environment/namespace format for AKS resources
      strategy:
        runOnce:
          deploy:
            steps:
              - task: KubernetesManifest@1
                displayName: 'Create imagePullSecret'
                inputs:
                  action: 'createSecret'
                  secretType: 'dockerRegistry'
                  secretName: 'acr-secret'
                  dockerRegistryEndpoint: 'MyACRServiceConnection'

              - task: KubernetesManifest@1
                displayName: 'Deploy to Kubernetes'
                inputs:
                  action: 'deploy'
                  manifests: |
                    k8s/deployment.yaml
                    k8s/service.yaml
                  containers: |
                    myappregistry.azurecr.io/myapp/api:$(Build.BuildId)
                  imagePullSecrets: 'acr-secret'

Real failure scenario: deployment job hung in “In progress”

A deployment job can get stuck in “In progress” indefinitely when an approval gate has been configured on an environment but the approver never receives the notification. This happens when the approver email is incorrect, the notification settings are turned off for the Azure DevOps organization, or the approval check was created with a zero-timeout (which causes it to immediately fail rather than wait).

To diagnose and recover:

# Check if a pipeline run is stuck
az pipelines runs show --id <run-id> --output table

# Cancel a stuck run
az pipelines runs cancel --id <run-id>

# List environment checks (approvals)
az devops invoke \
  --area pipelines \
  --resource approvals \
  --route-parameters runId=<run-id> \
  --output json
Tip

Set a timeout on environment approval checks. In the environment settings, configure the approval check with a maximum waiting time (for example, 24 hours). This prevents pipelines from waiting forever and consuming a parallel job slot indefinitely. The run will fail cleanly rather than hanging.

Reusing steps with pipeline templates

If multiple pipelines share the same deployment steps, extract them into a YAML template file and reference it from each pipeline. Templates accept parameters and can be stored in a separate repository.

# templates/deploy-appservice.yml
parameters:
  - name: environment
    type: string
  - name: appName
    type: string
  - name: azureSubscription
    type: string

jobs:
  - deployment: Deploy_${{ parameters.environment }}
    environment: ${{ parameters.environment }}
    strategy:
      runOnce:
        deploy:
          steps:
            - download: current
              artifact: drop

            - task: AzureWebApp@1
              displayName: 'Deploy to ${{ parameters.environment }}'
              inputs:
                azureSubscription: ${{ parameters.azureSubscription }}
                appType: 'webApp'
                appName: ${{ parameters.appName }}
                package: '$(Pipeline.Workspace)/drop/**/*.zip'
# azure-pipelines.yml — calling the template
stages:
  - stage: Deploy_Dev
    jobs:
      - template: templates/deploy-appservice.yml
        parameters:
          environment: 'dev'
          appName: 'myapp-dev'
          azureSubscription: 'My Azure Subscription'

  - stage: Deploy_Prod
    jobs:
      - template: templates/deploy-appservice.yml
        parameters:
          environment: 'prod'
          appName: 'myapp-prod'
          azureSubscription: 'My Azure Subscription'

Common mistakes

  1. Using regular jobs instead of deployment jobs for environment deployments. Regular jobs do not record deployment history, cannot pause for approvals, and do not integrate with Azure DevOps Environments. Any step that changes a running environment should use a deployment job.
  2. Not downloading the artifact before deploying. Deployment jobs do not automatically have access to artifacts from the Build stage. You must explicitly download the artifact with a download step, or the deployment tasks will not find any files to deploy.
  3. Triggering production deploys on every branch push. The trigger should be scoped to the main branch. Add a condition like eq(variables[‘Build.SourceBranch’], ‘refs/heads/main’) on your production stage to prevent feature branch builds from accidentally deploying to production.

Frequently asked questions

What is the difference between a pipeline stage and an environment in Azure Pipelines?

A stage is a logical division in your pipeline YAML that groups jobs. An environment is an Azure DevOps resource that represents a deployment target (like dev, staging, or prod). Environments have approval gates, deployment history, and resource tracking. A stage uses an environment by specifying it in the deployment job. Without environments, stages have no approval mechanism.

How do I require manual approval before deploying to production?

Create an Azure DevOps Environment named "prod" in the Environments section of Azure Pipelines. In the environment settings, add an Approvals check. Add a user or group as required approvers. When a pipeline reaches a deployment job targeting that environment, it pauses and sends a notification to approvers. The pipeline resumes only after approval.

Can I deploy from Azure Pipelines to Kubernetes?

Yes. Use the KubernetesManifest@1 task or the Kubernetes@1 task. Create a Kubernetes service connection pointing to your AKS cluster. The pipeline authenticates with the cluster and applies your manifest files or Helm charts.

Last verified: 19 March 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.