Multi-Stage Deployment Pipelines with Environment Approvals
A comprehensive guide to building multi-stage Azure DevOps YAML pipelines with environment approvals, deployment strategies, and rollback patterns for Node.js applications.
Multi-Stage Deployment Pipelines with Environment Approvals
Overview
Multi-stage pipelines in Azure DevOps let you model your entire build-test-deploy workflow in a single YAML file, with approval gates between environments that prevent untested code from reaching production. If you are still deploying with classic release pipelines or pushing manually, you are leaving safety and repeatability on the table. This article walks through everything: stage definitions, environment resources, approval configurations, deployment strategies, conditional logic, and a complete working pipeline that takes a Node.js application from source commit to production with gates at every boundary.
Prerequisites
- An Azure DevOps organization and project with Repos, Pipelines, and Environments enabled
- A Node.js application with a working
npm testscript and apackage.json - Basic understanding of YAML pipeline syntax (triggers, pools, steps)
- Azure service connections configured for your target infrastructure (App Service, AKS, or VMs)
- Familiarity with Git branching workflows
Defining Stages for Build, Test, and Deploy
A stage is the top-level organizational unit in a multi-stage pipeline. Each stage runs on its own agent, can depend on other stages, and can target a specific environment. The simplest useful pipeline has three stages: build, test, deploy.
trigger:
branches:
include:
- main
stages:
- stage: Build
displayName: 'Build Application'
jobs:
- job: BuildJob
pool:
vmImage: 'ubuntu-latest'
steps:
- task: NodeTool@0
inputs:
versionSpec: '20.x'
displayName: 'Install Node.js 20.x'
- script: npm ci
displayName: 'Install dependencies'
- script: npm run build --if-present
displayName: 'Build application'
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
includeRootFolder: false
archiveType: 'zip'
archiveFile: '$(Build.ArtifactStagingDirectory)/app-$(Build.BuildId).zip'
displayName: 'Archive application'
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)'
artifact: 'drop'
displayName: 'Publish artifact'
- stage: Test
displayName: 'Run Tests'
dependsOn: Build
jobs:
- job: UnitTests
pool:
vmImage: 'ubuntu-latest'
steps:
- task: NodeTool@0
inputs:
versionSpec: '20.x'
- script: npm ci
displayName: 'Install dependencies'
- script: npm test
displayName: 'Run unit tests'
- stage: Deploy
displayName: 'Deploy to Production'
dependsOn: Test
jobs:
- deployment: DeployWeb
pool:
vmImage: 'ubuntu-latest'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: drop
- script: echo "Deploying build $(Build.BuildId)"
displayName: 'Deploy application'
The dependsOn keyword controls execution order. Without it, stages run in parallel. With it, a stage waits for its dependencies to succeed before starting. If any dependency fails, the dependent stage is skipped.
A few things to notice. The Build stage produces an artifact. The Test stage runs independently from source. The Deploy stage uses a deployment job instead of a regular job, which is required for environment targeting. I will get into deployment jobs shortly.
Environment Resources in Azure DevOps
Environments are first-class resources in Azure DevOps. They represent a deployment target — dev, staging, production — and they carry their own approval policies, deployment history, and resource mappings.
You create environments in Pipelines > Environments in the Azure DevOps portal. Referencing an environment name in your YAML that does not exist yet will auto-create it on the first pipeline run, but it will have no approvals configured. Always create environments explicitly before your first deployment.
Creating Environments
Navigate to your project, then Pipelines > Environments > New environment. Give it a name that matches what you reference in your YAML:
developmentstagingproduction
For each environment, you can optionally register target resources:
- Kubernetes: Link an AKS cluster or any Kubernetes cluster
- Virtual machines: Register VMs as deployment targets
- App Service (implicit): Referenced through Azure service connections in deployment tasks
Environment Permissions
Environment permissions are separate from pipeline permissions. You can restrict who can create, manage, and approve deployments to an environment. This is critical for production — only senior engineers or team leads should be listed as approvers.
Go to the environment, click the three-dot menu, and select Approvals and checks. This is where you configure gates.
Configuring Approval Gates
Approval gates are the mechanism that prevents a pipeline from progressing to a stage without human sign-off. In Azure DevOps, approvals are configured on the environment, not on the pipeline itself. This means any pipeline that targets that environment inherits the approval requirement.
Setting Up Approvals
- Navigate to Pipelines > Environments > [your environment]
- Click the three-dot menu icon and select Approvals and checks
- Click Approvals
- Add individual users or groups as approvers
- Configure the approval policy:
- Minimum number of approvers: How many people must approve (default 1)
- Allow approvers to approve their own runs: Disable this for production
- Timeout: How long the pipeline waits for approval before failing (default 30 days)
- Instructions: A message shown to approvers explaining what they are approving
Other Check Types
Beyond manual approvals, Azure DevOps supports several automated checks:
- Branch control: Only allow deployments from specific branches (e.g., only
maincan deploy to production) - Business hours: Restrict deployments to specific time windows
- Invoke Azure Function: Call a custom function that returns pass/fail
- Invoke REST API: Hit an external endpoint for validation
- Required template: Ensure the pipeline extends from a specific template
- Exclusive lock: Prevent concurrent deployments to the same environment
A production environment typically combines at least three checks: manual approval from a senior engineer, branch control limiting to main, and business hours restricting deploys to weekdays 9am-4pm.
# When a pipeline hits this stage, it pauses and waits for approval
# because the 'production' environment has approval checks configured
- stage: DeployProd
displayName: 'Deploy to Production'
dependsOn: DeployStaging
jobs:
- deployment: DeployApp
pool:
vmImage: 'ubuntu-latest'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: drop
- task: AzureWebApp@1
inputs:
azureSubscription: 'Azure-Prod-Connection'
appType: 'webAppLinux'
appName: 'myapp-prod'
package: '$(Pipeline.Workspace)/drop/*.zip'
The pipeline will pause at the DeployProd stage and show a "Review" button in the Azure DevOps UI. An approver clicks it, sees the deployment details, and either approves or rejects. No code changes required in the YAML — it is entirely driven by the environment configuration.
Deployment Strategies
Azure DevOps supports three deployment strategies out of the box. Each one defines lifecycle hooks that run at different phases of the deployment.
RunOnce
The simplest strategy. Deploy once, and if it fails, you deal with it.
strategy:
runOnce:
preDeploy:
steps:
- script: echo "Validating pre-deployment requirements"
deploy:
steps:
- script: echo "Deploying application"
routeTraffic:
steps:
- script: echo "Switching traffic to new deployment"
postRouteTraffic:
steps:
- script: echo "Running smoke tests against live traffic"
on:
failure:
steps:
- script: echo "Deployment failed, executing rollback"
success:
steps:
- script: echo "Deployment succeeded"
Use runOnce for development and staging environments, or when your application cannot support multiple simultaneous versions.
Rolling
Deploys to a subset of targets at a time. If you have 10 VMs registered in an environment, a rolling deployment with maxParallel: 2 updates two at a time.
strategy:
rolling:
maxParallel: 2
preDeploy:
steps:
- script: echo "Pre-deploy on $(Environment.ResourceName)"
deploy:
steps:
- script: |
echo "Deploying to $(Environment.ResourceName)"
cd /opt/myapp
tar -xzf $(Pipeline.Workspace)/drop/app.tar.gz
pm2 restart ecosystem.config.js
displayName: 'Deploy and restart Node.js app'
on:
failure:
steps:
- script: |
echo "Rolling back on $(Environment.ResourceName)"
cd /opt/myapp
pm2 restart ecosystem.config.js --update-env
Rolling deployments require VM resources registered in the environment. They are not applicable to Azure App Service or Kubernetes — those have their own rolling update mechanisms.
Canary
Routes a percentage of traffic to the new version before promoting it fully. Azure DevOps tracks the increments you specify.
strategy:
canary:
increments: [10, 50]
preDeploy:
steps:
- script: echo "Canary pre-deploy, increment $(Strategy.CycleCount)"
deploy:
steps:
- script: echo "Deploying canary with $(Strategy.Increment)% traffic"
routeTraffic:
steps:
- script: echo "Routing $(Strategy.Increment)% traffic to canary"
postRouteTraffic:
steps:
- script: |
echo "Running validation against canary at $(Strategy.Increment)%"
# Monitor error rates, latency, and key metrics
sleep 60
echo "Canary metrics look healthy"
on:
failure:
steps:
- script: echo "Canary failed at $(Strategy.Increment)%, rolling back"
success:
steps:
- script: echo "Canary promoted to 100%"
In practice, canary deployments in Azure DevOps require external traffic management — Azure Traffic Manager, App Service deployment slots, or a service mesh like Istio on Kubernetes. The routeTraffic and postRouteTraffic hooks are where you implement the actual traffic shifting.
Blue-Green with Deployment Slots
Azure DevOps does not have a built-in "blue-green" strategy keyword, but you implement blue-green deployments using App Service deployment slots. Deploy to a staging slot, validate, then swap.
- stage: DeployProd
jobs:
- deployment: BlueGreenDeploy
pool:
vmImage: 'ubuntu-latest'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: drop
# Deploy to staging slot (blue)
- task: AzureWebApp@1
inputs:
azureSubscription: 'Azure-Prod-Connection'
appType: 'webAppLinux'
appName: 'myapp-prod'
deployToSlotOrASE: true
slotName: 'staging'
package: '$(Pipeline.Workspace)/drop/*.zip'
displayName: 'Deploy to staging slot'
# Validate the staging slot
- script: |
echo "Waiting for staging slot to warm up..."
sleep 30
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://myapp-prod-staging.azurewebsites.net/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed with status $STATUS"
exit 1
fi
echo "Staging slot is healthy"
displayName: 'Health check staging slot'
# Swap staging to production
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'Azure-Prod-Connection'
action: 'Swap Slots'
webAppName: 'myapp-prod'
sourceSlot: 'staging'
targetSlot: 'production'
displayName: 'Swap staging to production'
The beauty of slot swapping is that rollback is instant — swap again and you are back to the previous version. The old version sits in the staging slot, warm and ready.
Conditional Deployments
Not every commit should deploy to every environment. Use conditions to control when stages execute.
stages:
- stage: Build
jobs:
- job: BuildJob
pool:
vmImage: 'ubuntu-latest'
steps:
- script: npm ci && npm run build --if-present
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)'
artifact: 'drop'
# Dev deploys on every push to main
- stage: DeployDev
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployDevApp
environment: 'development'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to dev"
# Staging only deploys from release branches or tags
- stage: DeployStaging
dependsOn: DeployDev
condition: |
and(
succeeded(),
or(
startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'),
startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
)
)
jobs:
- deployment: DeployStagingApp
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to staging"
# Production only deploys from tags
- stage: DeployProd
dependsOn: DeployStaging
condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/v'))
jobs:
- deployment: DeployProdApp
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to production"
Stage-level condition expressions are evaluated at runtime. A false condition skips the stage entirely — it does not fail. Downstream stages that depend on a skipped stage also skip unless you explicitly handle it with condition: not(failed()) or condition: always().
Variable Groups Per Environment
Variable groups let you define sets of variables that map to specific environments. Create them in Pipelines > Library and link them to stages.
stages:
- stage: DeployDev
variables:
- group: 'dev-variables'
jobs:
- deployment: Deploy
environment: 'development'
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Deploying to $(APP_URL)"
echo "Database: $(DB_HOST)"
- stage: DeployProd
variables:
- group: 'prod-variables'
jobs:
- deployment: Deploy
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Deploying to $(APP_URL)"
echo "Database: $(DB_HOST)"
For secrets, mark variables as secret in the variable group. Azure DevOps will mask their values in logs. For more sensitive scenarios, use Azure Key Vault variable groups that pull secrets directly from Key Vault at runtime.
variables:
- group: 'prod-variables' # Regular variables
- group: 'prod-keyvault-secrets' # Linked to Azure Key Vault
Each variable group can also be scoped to specific pipelines and require approval before use. This prevents a rogue pipeline from accessing production secrets.
Deployment Jobs vs Regular Jobs
A deployment job is different from a regular job in three important ways:
- Environment targeting: Deployment jobs reference an
environment, which triggers any approval gates configured on that environment. Regular jobs cannot target environments. - Deployment history: Azure DevOps tracks which builds were deployed to which environments, giving you a complete audit trail. Regular jobs do not appear in environment history.
- Strategy support: Only deployment jobs support
runOnce,rolling, andcanarystrategies. Regular jobs execute steps directly.
# Regular job - for build, test, analysis tasks
jobs:
- job: RunTests
pool:
vmImage: 'ubuntu-latest'
steps:
- script: npm test
# Deployment job - for deploying to environments
jobs:
- deployment: DeployApp
pool:
vmImage: 'ubuntu-latest'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: drop
- script: echo "Deploying"
A common mistake is using regular jobs for deployments. It works — the code deploys — but you lose environment tracking, approval gates, and deployment strategies. Always use deployment jobs for anything that modifies a live environment.
Pipeline Triggers and Scheduling
Multi-stage pipelines support multiple trigger types.
CI Triggers
trigger:
branches:
include:
- main
- release/*
exclude:
- feature/experimental/*
paths:
include:
- src/**
- package.json
exclude:
- docs/**
- '*.md'
batch: true # Batch concurrent changes into a single run
The batch: true setting is important for busy repositories. Without it, every push starts a new pipeline run. With batching, concurrent pushes queue up and run together.
Scheduled Triggers
schedules:
- cron: '0 2 * * 1-5'
displayName: 'Nightly deploy to staging'
branches:
include:
- main
always: false # Only run if there are new changes
Scheduled pipelines are useful for deploying to staging overnight so QA has a fresh environment every morning.
PR Triggers
pr:
branches:
include:
- main
paths:
include:
- src/**
PR triggers only run the build and test stages. Use conditions on deployment stages to prevent PR builds from deploying.
Rollback Patterns
Rollback is the part most teams skip during pipeline setup and regret during an incident. Here are three patterns that work in production.
Pattern 1: Slot Swap Rollback
If you deployed with App Service slot swapping, rollback is a reverse swap.
# Manual rollback pipeline (triggered manually when needed)
trigger: none
parameters:
- name: environment
type: string
values:
- staging
- production
default: production
stages:
- stage: Rollback
jobs:
- deployment: RollbackDeploy
environment: '${{ parameters.environment }}'
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'Azure-Prod-Connection'
action: 'Swap Slots'
webAppName: 'myapp-prod'
sourceSlot: 'staging'
targetSlot: 'production'
displayName: 'Swap back to previous version'
Pattern 2: Redeploy Previous Artifact
This pattern finds the last successful deployment artifact and redeploys it.
# In your Node.js health check script
# health-check.js
var http = require('http');
function checkHealth(url, retries, delay) {
return new Promise(function(resolve, reject) {
var attempts = 0;
function attempt() {
attempts++;
http.get(url, function(res) {
if (res.statusCode === 200) {
console.log('Health check passed on attempt ' + attempts);
resolve(true);
} else if (attempts < retries) {
console.log('Attempt ' + attempts + '/' + retries + ': status ' + res.statusCode + ', retrying in ' + delay + 'ms...');
setTimeout(attempt, delay);
} else {
reject(new Error('Health check failed after ' + retries + ' attempts. Last status: ' + res.statusCode));
}
}).on('error', function(err) {
if (attempts < retries) {
console.log('Attempt ' + attempts + '/' + retries + ': ' + err.message + ', retrying...');
setTimeout(attempt, delay);
} else {
reject(new Error('Health check failed after ' + retries + ' attempts: ' + err.message));
}
});
}
attempt();
});
}
var targetUrl = process.env.HEALTH_CHECK_URL || 'http://localhost:8080/health';
var maxRetries = parseInt(process.env.HEALTH_CHECK_RETRIES) || 10;
var retryDelay = parseInt(process.env.HEALTH_CHECK_DELAY) || 5000;
checkHealth(targetUrl, maxRetries, retryDelay)
.then(function() {
console.log('Application is healthy');
process.exit(0);
})
.catch(function(err) {
console.error('FATAL: ' + err.message);
process.exit(1);
});
Pattern 3: Automated Rollback on Health Check Failure
Wire the health check into the pipeline with automatic rollback on failure.
- stage: DeployProd
jobs:
- deployment: DeployApp
environment: 'production'
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: drop
- task: AzureWebApp@1
inputs:
azureSubscription: 'Azure-Prod-Connection'
appType: 'webAppLinux'
appName: 'myapp-prod'
deployToSlotOrASE: true
slotName: 'staging'
package: '$(Pipeline.Workspace)/drop/*.zip'
displayName: 'Deploy to staging slot'
- script: |
RETRIES=0
MAX=12
URL="https://myapp-prod-staging.azurewebsites.net/health"
while [ $RETRIES -lt $MAX ]; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL" || echo "000")
if [ "$STATUS" = "200" ]; then
echo "##[section]Health check passed"
exit 0
fi
RETRIES=$((RETRIES + 1))
echo "Attempt $RETRIES/$MAX: HTTP $STATUS. Retrying in 10s..."
sleep 10
done
echo "##vso[task.logissue type=error]Health check failed after $MAX attempts"
exit 1
displayName: 'Health check staging slot'
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'Azure-Prod-Connection'
action: 'Swap Slots'
webAppName: 'myapp-prod'
sourceSlot: 'staging'
targetSlot: 'production'
displayName: 'Swap to production'
on:
failure:
steps:
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'Azure-Prod-Connection'
action: 'Stop Azure App Service'
webAppName: 'myapp-prod'
specifySlotOrASE: true
slotName: 'staging'
displayName: 'Stop failed staging slot'
- script: echo "##vso[task.logissue type=warning]Deployment rolled back automatically"
displayName: 'Log rollback event'
The on: failure hook runs automatically when any step in the deploy lifecycle fails. This is built into the deployment strategy — no extra scripting required.
Complete Working Example
Here is a full multi-stage YAML pipeline that builds a Node.js application, runs tests, and deploys through dev, staging, and production with approval gates between each stage.
# azure-pipelines.yml
# Multi-stage pipeline for Node.js with environment approvals
trigger:
branches:
include:
- main
- release/*
paths:
exclude:
- docs/**
- '*.md'
- '.vscode/**'
pr:
branches:
include:
- main
variables:
- name: nodeVersion
value: '20.x'
- name: npmCacheFolder
value: '$(Pipeline.Workspace)/.npm'
- name: artifactName
value: 'nodejs-app'
stages:
# =====================================================
# Stage 1: Build and Unit Test
# =====================================================
- stage: Build
displayName: 'Build & Unit Test'
jobs:
- job: BuildAndTest
pool:
vmImage: 'ubuntu-latest'
steps:
- task: NodeTool@0
inputs:
versionSpec: '$(nodeVersion)'
displayName: 'Install Node.js $(nodeVersion)'
- task: Cache@2
inputs:
key: 'npm | "$(Agent.OS)" | package-lock.json'
restoreKeys: |
npm | "$(Agent.OS)"
path: '$(npmCacheFolder)'
displayName: 'Cache npm packages'
- script: npm ci --cache $(npmCacheFolder)
displayName: 'Install dependencies'
# Typical output: added 847 packages in 12.3s
- script: npm run lint --if-present
displayName: 'Run linter'
- script: npm test -- --reporter mocha-junit-reporter --reporter-options mochaFile=$(Common.TestResultsDirectory)/test-results.xml
displayName: 'Run unit tests'
env:
NODE_ENV: test
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '$(Common.TestResultsDirectory)/test-results.xml'
testRunTitle: 'Unit Tests - Build $(Build.BuildId)'
displayName: 'Publish test results'
- script: |
echo "Creating deployment package..."
mkdir -p $(Build.ArtifactStagingDirectory)/app
cp -r server.js package.json package-lock.json routes/ models/ views/ static/ utils/ $(Build.ArtifactStagingDirectory)/app/
cd $(Build.ArtifactStagingDirectory)/app
npm ci --production --cache $(npmCacheFolder)
echo "Package size: $(du -sh . | cut -f1)"
displayName: 'Create production package'
# Typical output: Package size: 24M
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/app'
includeRootFolder: false
archiveType: 'zip'
archiveFile: '$(Build.ArtifactStagingDirectory)/$(artifactName)-$(Build.BuildId).zip'
displayName: 'Archive application'
# Typical output: Archive size: ~8.2 MB
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/$(artifactName)-$(Build.BuildId).zip'
artifact: '$(artifactName)'
displayName: 'Publish artifact'
# =====================================================
# Stage 2: Deploy to Development
# No approval required - auto-deploys on successful build
# =====================================================
- stage: DeployDev
displayName: 'Deploy to Development'
dependsOn: Build
condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
variables:
- group: 'dev-variables'
jobs:
- deployment: DeployDevApp
pool:
vmImage: 'ubuntu-latest'
environment: 'development'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: '$(artifactName)'
- task: AzureWebApp@1
inputs:
azureSubscription: 'Azure-Dev-ServiceConnection'
appType: 'webAppLinux'
appName: '$(APP_SERVICE_NAME)'
runtimeStack: 'NODE|20-lts'
package: '$(Pipeline.Workspace)/$(artifactName)/*.zip'
startUpCommand: 'npm start'
displayName: 'Deploy to Dev App Service'
- script: |
echo "Waiting 20 seconds for app to start..."
sleep 20
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$(APP_SERVICE_NAME).azurewebsites.net/health" || echo "000")
if [ "$STATUS" = "200" ]; then
echo "##[section]Dev deployment healthy - HTTP 200"
else
echo "##vso[task.logissue type=error]Dev health check failed - HTTP $STATUS"
exit 1
fi
displayName: 'Health check - Development'
- job: SmokeTests
dependsOn: DeployDevApp
pool:
vmImage: 'ubuntu-latest'
steps:
- task: NodeTool@0
inputs:
versionSpec: '$(nodeVersion)'
- script: |
npm ci
npm run test:smoke -- --base-url "https://$(APP_SERVICE_NAME).azurewebsites.net"
displayName: 'Run smoke tests against dev'
env:
NODE_ENV: development
# =====================================================
# Stage 3: Deploy to Staging
# Requires approval from QA team (configured on environment)
# =====================================================
- stage: DeployStaging
displayName: 'Deploy to Staging'
dependsOn: DeployDev
condition: succeeded()
variables:
- group: 'staging-variables'
jobs:
- deployment: DeployStagingApp
pool:
vmImage: 'ubuntu-latest'
environment: 'staging'
strategy:
runOnce:
preDeploy:
steps:
- script: |
echo "##[section]Pre-deployment validation for Staging"
echo "Build: $(Build.BuildId)"
echo "Source: $(Build.SourceBranchName)"
echo "Commit: $(Build.SourceVersion)"
displayName: 'Pre-deployment info'
deploy:
steps:
- download: current
artifact: '$(artifactName)'
- task: AzureWebApp@1
inputs:
azureSubscription: 'Azure-Staging-ServiceConnection'
appType: 'webAppLinux'
appName: '$(APP_SERVICE_NAME)'
runtimeStack: 'NODE|20-lts'
package: '$(Pipeline.Workspace)/$(artifactName)/*.zip'
startUpCommand: 'npm start'
displayName: 'Deploy to Staging App Service'
postRouteTraffic:
steps:
- script: |
RETRIES=0
MAX=10
URL="https://$(APP_SERVICE_NAME).azurewebsites.net/health"
while [ $RETRIES -lt $MAX ]; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL" || echo "000")
if [ "$STATUS" = "200" ]; then
echo "##[section]Staging health check passed"
exit 0
fi
RETRIES=$((RETRIES + 1))
echo "Attempt $RETRIES/$MAX: HTTP $STATUS"
sleep 10
done
echo "##vso[task.logissue type=error]Staging health check failed"
exit 1
displayName: 'Health check - Staging'
on:
failure:
steps:
- script: |
echo "##vso[task.logissue type=warning]Staging deployment failed for build $(Build.BuildId)"
echo "Notifying the team..."
displayName: 'Handle staging failure'
- job: IntegrationTests
dependsOn: DeployStagingApp
pool:
vmImage: 'ubuntu-latest'
steps:
- task: NodeTool@0
inputs:
versionSpec: '$(nodeVersion)'
- script: |
npm ci
npm run test:integration -- --base-url "https://$(APP_SERVICE_NAME).azurewebsites.net"
displayName: 'Run integration tests against staging'
env:
NODE_ENV: staging
TEST_API_KEY: $(TEST_API_KEY)
# =====================================================
# Stage 4: Deploy to Production
# Requires approval from engineering lead + branch control
# Uses blue-green deployment with slot swap
# =====================================================
- stage: DeployProd
displayName: 'Deploy to Production'
dependsOn: DeployStaging
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
variables:
- group: 'prod-variables'
- group: 'prod-keyvault-secrets'
jobs:
- deployment: DeployProdApp
pool:
vmImage: 'ubuntu-latest'
environment: 'production'
strategy:
runOnce:
preDeploy:
steps:
- script: |
echo "##[section]Production Deployment"
echo "================================================"
echo "Build ID: $(Build.BuildId)"
echo "Source: $(Build.SourceBranch)"
echo "Commit: $(Build.SourceVersion)"
echo "Requested: $(Build.RequestedFor)"
echo "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "================================================"
displayName: 'Production deployment manifest'
deploy:
steps:
- download: current
artifact: '$(artifactName)'
# Deploy to staging slot first
- task: AzureWebApp@1
inputs:
azureSubscription: 'Azure-Prod-ServiceConnection'
appType: 'webAppLinux'
appName: '$(APP_SERVICE_NAME)'
deployToSlotOrASE: true
slotName: 'staging'
runtimeStack: 'NODE|20-lts'
package: '$(Pipeline.Workspace)/$(artifactName)/*.zip'
startUpCommand: 'npm start'
displayName: 'Deploy to production staging slot'
# Validate staging slot
- script: |
echo "Waiting 30 seconds for staging slot warm-up..."
sleep 30
RETRIES=0
MAX=15
URL="https://$(APP_SERVICE_NAME)-staging.azurewebsites.net/health"
while [ $RETRIES -lt $MAX ]; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL" || echo "000")
BODY=$(curl -s "$URL" || echo "{}")
if [ "$STATUS" = "200" ]; then
echo "##[section]Staging slot healthy"
echo "Response: $BODY"
exit 0
fi
RETRIES=$((RETRIES + 1))
echo "Attempt $RETRIES/$MAX: HTTP $STATUS. Waiting 10s..."
sleep 10
done
echo "##vso[task.logissue type=error]Staging slot health check failed after $MAX attempts"
exit 1
displayName: 'Health check staging slot'
# Swap staging to production
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'Azure-Prod-ServiceConnection'
action: 'Swap Slots'
webAppName: '$(APP_SERVICE_NAME)'
sourceSlot: 'staging'
targetSlot: 'production'
displayName: 'Swap staging to production'
# Validate production after swap
- script: |
sleep 15
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$(APP_SERVICE_NAME).azurewebsites.net/health")
if [ "$STATUS" = "200" ]; then
echo "##[section]Production is healthy after swap"
else
echo "##vso[task.logissue type=error]Production health check failed after swap - HTTP $STATUS"
exit 1
fi
displayName: 'Verify production health'
on:
failure:
steps:
# Auto-rollback: swap back
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'Azure-Prod-ServiceConnection'
action: 'Swap Slots'
webAppName: '$(APP_SERVICE_NAME)'
sourceSlot: 'staging'
targetSlot: 'production'
displayName: 'ROLLBACK: Swap production back to previous version'
- script: |
echo "##vso[task.logissue type=error]Production deployment ROLLED BACK for build $(Build.BuildId)"
echo "Previous version has been restored via slot swap"
displayName: 'Log rollback event'
What This Pipeline Does
Build stage: Installs dependencies, runs linting and unit tests, creates a production-only deployment package (excluding dev dependencies), archives it as a zip, and publishes the artifact. Typical build time: 2-3 minutes.
Dev deployment: Auto-deploys after a successful build (no approval needed). Runs a health check and smoke tests against the deployed app. Skips entirely on PR builds.
Staging deployment: Pauses for approval from the QA team (configured on the
stagingenvironment). Deploys, runs a health check with retries, then runs integration tests. Notifies the team on failure.Production deployment: Pauses for approval from an engineering lead (configured on the
productionenvironment). Uses blue-green deployment via App Service slot swap. Deploys to a staging slot, validates it, swaps to production, then validates again. Automatically rolls back by swapping again if any step fails.
Environment Setup Required
For this pipeline to work, create three environments in Azure DevOps:
| Environment | Approvals | Branch Control | Business Hours |
|---|---|---|---|
| development | None | None | None |
| staging | QA team (1 approver minimum) | main, release/* | None |
| production | Engineering lead (1 approver, no self-approval) | main only | Mon-Fri 9am-4pm |
Create three variable groups in Pipelines > Library:
dev-variables:APP_SERVICE_NAME = myapp-devstaging-variables:APP_SERVICE_NAME = myapp-staging,TEST_API_KEY = (secret)prod-variables:APP_SERVICE_NAME = myapp-prodprod-keyvault-secrets: Linked to Azure Key Vault for production secrets
Common Issues and Troubleshooting
1. "Stage DeployProd was skipped because condition was not met"
Stage DeployProd was skipped
Condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
Evaluated: and(True, False)
Cause: You pushed from a feature branch or a release branch, and the production stage condition requires refs/heads/main. This is working as designed, but it catches people off guard when they expect a tag-based trigger to work.
Fix: Adjust the condition to match your branching strategy. If you deploy to production from release branches:
condition: |
and(
succeeded(),
or(
eq(variables['Build.SourceBranch'], 'refs/heads/main'),
startsWith(variables['Build.SourceBranch'], 'refs/heads/release/')
)
)
2. "No environment found with name 'production'"
##[error]No environment found with name 'production'.
The environment does not exist or has not been authorized for use.
Cause: Either the environment does not exist yet, or the pipeline does not have permission to access it. Azure DevOps auto-creates environments on first reference, but only if the pipeline has permission to create resources.
Fix: Manually create the environment in Pipelines > Environments before running the pipeline. Then go to the environment's security settings and grant the pipeline access. If using a project-scoped service account, ensure it has the "Administrator" role on the environment.
3. "The deployment job 'DeployApp' cannot use both 'environment' and 'pool' at the same level"
/azure-pipelines.yml (Line: 45, Col: 9): A deployment job cannot specify both a strategy and steps.
Cause: You mixed regular job syntax with deployment job syntax. Deployment jobs require steps to be inside a strategy block (runOnce, rolling, or canary), not directly under steps.
Fix: Move your steps inside the strategy:
# WRONG
- deployment: Deploy
environment: 'production'
steps:
- script: echo "deploy"
# CORRECT
- deployment: Deploy
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: echo "deploy"
4. "Approval for deployment to environment 'production' timed out"
##[warning]Approval for deployment to environment 'production' timed out after 72:00:00.
Stage was skipped.
Cause: Nobody approved the deployment within the timeout window configured on the environment. The default timeout is 30 days, but many teams set it to 72 hours or less.
Fix: Set a realistic timeout in the environment approval settings. For production, 24-48 hours is reasonable — if nobody approves within that window, the change should go through a new build anyway. Also configure email notifications so approvers know a deployment is waiting. Navigate to Pipelines > Environments > production > Approvals and checks and adjust the timeout value.
5. "Artifact 'drop' not found"
##[error]Artifact 'drop' not found. Please make sure the artifact has been published in a prior stage/job.
Cause: The artifact name in the download step does not match the name used in PublishPipelineArtifact. This also happens when a deployment stage runs but its dependency (the build stage) was skipped due to a condition.
Fix: Verify the artifact name matches exactly. In the complete example above, the artifact is named nodejs-app, not drop. Cross-reference the PublishPipelineArtifact step with every download step:
# Publishing (in Build stage)
- task: PublishPipelineArtifact@1
inputs:
artifact: 'nodejs-app' # This name...
# Downloading (in Deploy stage)
- download: current
artifact: 'nodejs-app' # ...must match this name
Best Practices
Create environments explicitly before your first pipeline run. Do not rely on auto-creation. Auto-created environments have no approvals, no branch controls, and no audit trail of who created them.
Use deployment jobs for every stage that modifies a live environment. Even for dev environments. The deployment history and environment tracking are worth it, and switching to approval gates later requires zero YAML changes.
Separate build artifacts from deployment configuration. Build once, deploy many times. Your build stage should produce an environment-agnostic artifact. Environment-specific configuration comes from variable groups and app settings, not from the artifact itself.
Implement health checks with retry logic in every deployment stage. A single curl that fails on the first try will cause unnecessary rollbacks. Use 10-15 retries with 10-second intervals. Application cold starts on App Service can take 30-60 seconds.
Configure branch control on production environments. Approvals alone are not sufficient. A developer with approval access could approve their own feature branch deployment if branch control is not enforced. Restrict production to
mainorrelease/*branches.Use slot-based deployments for production. The ability to swap back in under 5 seconds is worth the extra App Service plan cost. You cannot get that rollback speed with redeployment.
Keep deployment stages idempotent. If a deployment stage is re-run (after an approval timeout or a transient failure), it should produce the same result. Avoid steps that fail on second execution, like creating database tables without
IF NOT EXISTS.Set approval timeouts to match your release cadence. If you ship daily, a 72-hour timeout is too long. If you ship weekly, 24 hours might be too short. Match the timeout to how long it realistically takes for an approver to review.
Use
batch: trueon CI triggers for active repositories. Without batching, five quick pushes to main start five separate pipeline runs. With batching, they consolidate into one or two runs, saving agent time and reducing approval fatigue.Tag your deployments. Add a step that creates a Git tag after a successful production deployment. This gives you an unambiguous record of exactly which commit is running in production.
- script: |
git tag "deploy-prod-$(Build.BuildId)" $(Build.SourceVersion)
git push origin "deploy-prod-$(Build.BuildId)"
displayName: 'Tag production deployment'
