Azure DevOps Pipelines: Complete Guide with Security Integration
Azure DevOps Pipelines: Complete Guide with Security Integration
Learn to build Azure DevOps YAML pipelines from scratch — CI builds, multi-stage deployments, Key Vault secrets, and security scanning built into every stage.
Difficulty: Intermediate Estimated Reading Time: 16 minutes Last Updated: 2026-02-15
Table of Contents
- What Are Azure DevOps Pipelines
- Creating Your First Pipeline
- Pipeline Structure Explained
- Variables and Secrets
- Adding Security Scanning
- Multi-Stage Deployments
- Approvals and Gates
- Reusable Templates
- Troubleshooting
- Best Practices
What Are Azure DevOps Pipelines
Azure DevOps Pipelines is Microsoft's CI/CD service. Pipelines are defined in a YAML file (azure-pipelines.yml) stored in your repository, so your build definition is versioned alongside your code.
The service is organised into five areas:
| Service | Purpose |
|---|---|
| Repos | Git repositories with branch policies |
| Pipelines | Build and release automation |
| Boards | Work item tracking |
| Artifacts | Package feeds (NuGet, npm, Maven) |
| Test Plans | Manual test management |
For DevSecOps, Pipelines matter because every stage of your delivery process — build, scan, test, deploy — runs through them. Security checks become pipeline steps, not afterthoughts.
Creating Your First Pipeline
Prerequisites
- Azure DevOps organisation at
dev.azure.com - A project with a repository
- Permissions to create pipelines
Minimal pipeline
Create azure-pipelines.yml in your repo root:
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- script: echo "Hello from Azure DevOps"
displayName: 'First step'
Commit and push. Azure DevOps detects the file and creates a pipeline run automatically.
A realistic Node.js build
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- task: NodeTool@0
inputs:
versionSpec: '20.x'
displayName: 'Install Node.js'
- script: npm ci
displayName: 'Install dependencies'
- script: npm run build
displayName: 'Build'
- script: npm test
displayName: 'Run tests'
Pipeline Structure Explained
Pipelines have three levels — stages contain jobs contain steps:
stages:
- stage: Build
jobs:
- job: CompileAndTest
steps:
- script: npm run build
- stage: Deploy
dependsOn: Build
jobs:
- job: DeployApp
steps:
- script: ./deploy.sh
| Level | Runs on | Parallel? |
|---|---|---|
| Stage | Logical grouping | Sequential by default (dependsOn controls order) |
| Job | One agent | Parallel within a stage |
| Step | Inside a job | Sequential |
Jobs on the same stage run in parallel across agents — useful for running unit tests and lint checks simultaneously:
- stage: Validate
jobs:
- job: Tests
steps:
- script: npm test
- job: Lint
steps:
- script: npm run lint
- job: TypeCheck
steps:
- script: npx tsc --noEmit
Variables and Secrets
Pipeline variables
variables:
- name: imageName
value: 'myapp'
- name: environment
value: 'staging'
steps:
- script: echo "Building $(imageName) for $(environment)"
Variable groups
Shared variables across pipelines go in Pipelines → Library → Variable groups. Mark sensitive values as secret — Azure DevOps masks them in logs.
Azure Key Vault integration
Never store secrets in YAML. Link a variable group to Key Vault, or fetch secrets in a task:
steps:
- task: AzureKeyVault@2
inputs:
azureSubscription: 'my-service-connection'
KeyVaultName: 'prod-secrets-kv'
SecretsFilter: 'api-key,db-password'
RunAsPreJob: true
Secrets fetched this way are available as $(api-key) and are masked in all pipeline output.
Adding Security Scanning
This is where pipelines become DevSecOps pipelines. Add scanning as early-stage jobs that fail the build on critical findings.
Dependency scanning
- script: npm audit --audit-level=high
displayName: 'Audit npm dependencies'
Container image scanning with Trivy
- script: |
docker build -t $(imageName):$(Build.BuildId) .
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --severity CRITICAL --exit-code 1 \
$(imageName):$(Build.BuildId)
displayName: 'Build and scan image'
--exit-code 1 makes the pipeline fail on critical vulnerabilities — the image never reaches your registry.
Secret detection
- script: |
docker run --rm -v $(Build.SourcesDirectory):/path \
zricethezav/gitleaks:latest detect --source /path --verbose
displayName: 'Scan for committed secrets'
Static analysis (SAST)
SonarQube has a native Azure DevOps extension:
- task: SonarQubePrepare@6
inputs:
SonarQube: 'SonarQubeServiceConnection'
scannerMode: 'cli'
configMode: 'manual'
cliProjectKey: 'myapp'
cliProjectName: 'My App'
- task: SonarQubeAnalyze@6
- task: SonarQubePublish@6
inputs:
pollingTimeoutSec: '300'
Multi-Stage Deployments
Use deployment jobs for environment-aware deployments with built-in tracking:
- stage: DeployStaging
dependsOn: Build
jobs:
- deployment: DeployToStaging
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- task: KubernetesManifest@1
inputs:
action: 'deploy'
kubernetesServiceConnection: 'aks-staging'
namespace: 'myapp'
manifests: '$(Build.ArtifactStagingDirectory)/manifests/*.yaml'
- stage: DeployProduction
dependsOn: DeployStaging
jobs:
- deployment: DeployToProd
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: ./deploy-production.sh
The environment keyword is what unlocks approvals — see next section.
Approvals and Gates
In Pipelines → Environments → production → Approvals and checks, add required approvers. The pipeline pauses before touching production until a human approves.
Available checks:
| Check | What it does |
|---|---|
| Approvals | Named users must approve before deploy |
| Business hours | Only deploy during specified windows |
| Azure Function | Call a custom function that returns pass/fail |
| REST API | Query an external service for a go/no-go |
| Branch control | Only allow deploys from specific branches |
This gives you continuous delivery with a human safety gate — deploy to staging on every merge, require sign-off for production.
Reusable Templates
Extract repeated step sequences into template files:
# templates/build-node.yml
parameters:
- name: nodeVersion
default: '20.x'
steps:
- task: NodeTool@0
inputs:
versionSpec: '${{ parameters.nodeVersion }}'
- script: npm ci && npm run build
Use it in any pipeline:
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- template: templates/build-node.yml
parameters:
nodeVersion: '20.x'
Templates keep pipelines DRY — update the template once, every pipeline gets the fix.
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
| "No hosted parallelism" | Free tier limit reached | Request parallel jobs grant or use self-hosted agents |
##[error]Variable $(x) is not defined | Variable not in scope | Check dependsOn — variables don't cross stages without stageDependencies |
| Secrets print in logs | Using plain variables | Use secret variables or Key Vault task |
| Agent can't reach ACR/AKS | Service connection missing | Create ARM service connection in Project Settings |
| YAML validation error | Indentation wrong | Use the pipeline editor's Validate button |
dependsOn stage skipped | Previous stage failed | Check condition: succeeded() — default behaviour |
Debug a failing run with verbose logs:
variables:
- name: system.debug
value: 'true'
Best Practices
- YAML over Classic — pipeline definitions live in Git, get reviewed in PRs, and roll back with the code.
- Fail fast — run lint/tests/security scans before the slow build steps.
- Secret hygiene — Key Vault for all secrets; secret variables are write-only (you can't read them back in the UI).
- Branch policies — require a passing build on every PR to
main. - Environments for prod — approvals + business-hours checks on the production environment.
- Self-hosted agents for internal resources — if builds need to reach resources inside your VNET, host agents there.
- Cache dependencies — use
Cache@2fornode_modules/nugetto cut build times. - Template everything repeated — if two pipelines share steps, extract a template.