If you are diving into Azure DevOps—whether you are preparing for the AZ-400 Engineer Expert exam or trying to ship a real-world web application —mastering Pipelines as Code is a rite of passage.
In this post, we are going to break down how to transition from a manual build process to an automated, multi-stage CI/CD pipeline deploying seamlessly to Azure Static Web Apps.
1. The Blueprint: Why Multi-Stage Pipelines?
A robust release cycle separates Continuous Integration (CI) from Continuous Deployment (CD).
- The Build Stage (CI): Installs your dependencies, compiles your source code, runs automated tests, and packages the result into a portable asset (an artifact).
- The Deploy Stage (CD): Takes that packaged asset and safely pushes it to your live hosting environment.
By splitting these phases, you ensure that broken code never even touches your deployment pipeline.
2. The Production-Ready CI/CD Pipeline YAML
Here is the complete multi-stage pipeline configuration (azure-pipelines.yml) that handles building a modern web application and deploying it directly to Azure Static Web Apps using a pre-configured deployment token.
YAML
trigger:
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
node_version: '20.x'
build_folder: 'dist' # Change to 'build' or '.next' depending on your framework
stages:
# ========================================
# STAGE 1: BUILD (Continuous Integration)
# ========================================
- stage: Build
displayName: 'Build Stage'
jobs:
- job: BuildJob
displayName: 'Install and Build'
steps:
- task: NodeTool@0
inputs:
versionSpec: '$(node_version)'
displayName: 'Install Node.js'
- script: |
npm install
displayName: 'Install dependencies'
- script: |
npm run build
displayName: 'Build the application'
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(System.DefaultWorkingDirectory)/$(build_folder)'
artifact: 'drop'
publishLocation: 'pipeline'
displayName: 'Publish Build Artifacts'
# ========================================
# STAGE 2: DEPLOY (Continuous Deployment)
# ========================================
- stage: Deploy
displayName: 'Deploy Stage'
dependsOn: Build
condition: succeeded() # Only deploy if the build succeeds
jobs:
- job: DeployJob
displayName: 'Deploy to Azure Static Web Apps'
steps:
# 1. Download the artifact created in the Build stage
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'current'
artifactName: 'drop'
targetPath: '$(System.DefaultWorkingDirectory)/drop'
displayName: 'Download Build Artifacts'
# 2. Push the files to Azure
- task: AzureStaticWebApp@0
inputs:
app_location: '/drop' # Points to the downloaded artifact folder
skip_app_build: true # Tells Azure not to build again
skip_api_build: true
azure_static_web_apps_api_token: $(deployment_token)
displayName: 'Deploy to Azure Static Web Apps'
3. Decoding the Anatomy of a Pipeline
To write your own pipelines with confidence, you need to understand the underlying hierarchy. Think of a pipeline like a manufacturing plant:
- Pipeline (The Factory): Governed by global rules like
trigger(when work starts) andpool(what virtual machine environment to spin up). - Stage (The Departments): Major boundaries like
BuildandDeploy. Stages can enforce dependencies usingdependsOn: Build. - Job (The Worker): Execution blocks within a stage. Everything inside a single job runs sequentially on the exact same temporary virtual machine.
- Step (The Instructions): The granular actions executed by the worker. These are split into two types:
script:Raw terminal commands (e.g.,npm install,npm run build).task:Pre-packaged, version-locked automation provided by Microsoft (e.g.,NodeTool@0,PublishPipelineArtifact@1).
Let’s look under the hood. Azure Pipelines YAML is built on specific keywords and syntax rules. Understanding the difference between built-in system variables, custom variables, tasks, and scripts is key to mastering it.
Here is the exact code block by code block breakdown.
a. Triggers and Infrastructure
YAML
trigger:
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
trigger:Tells Azure DevOps what events cause this pipeline to run. Here, any commit or merged Pull Request hitting themainbranch starts a run.pool:Defines the “Agent Pool.” An agent is just a virtual machine (VM) that executes your pipeline.vmImage: 'ubuntu-latest'You are requesting a fresh, Microsoft-hosted Linux VM running the latest version of Ubuntu. Every time the pipeline runs, you get a clean, temporary machine that is destroyed when the job finishes.
b. Variables
YAML
variables:
node_version: '20.x'
build_folder: 'dist'
variables:A section to declare custom key-value pairs.- Instead of hardcoding
20.xordistdeep inside the pipeline steps, you define them at the top. Later, you reference them using the syntax$(variable_name).
c. Stages and Jobs (The Framework)
YAML
stages:
- stage: Build
displayName: 'Build Stage'
jobs:
- job: BuildJob
displayName: 'Install and Build'
steps:
stages:The highest level of organization. Stages are useful for separating environments (e.g., Build, Deploy to Test, Deploy to Prod).- stage: BuildThe hyphen-means this is an item in a list. We are naming this first stageBuild.displayName:This is simply the human-readable text that shows up in the Azure DevOps web interface.jobs:/- job: BuildJobA stage contains one or more jobs. Crucial concept: Everything inside a single job runs on the same virtual machine. If you had two jobs here, Azure would spin up two separate VMs to run them simultaneously.steps:The sequential list of actions the VM will perform.
d. Tasks vs. Scripts (The Steps)
YAML
- task: NodeTool@0
inputs:
versionSpec: '$(node_version)'
displayName: 'Install Node.js'
task:A pre-packaged, reusable script provided by Microsoft or the community. You don’t have to write the underlying logic; you just provide the inputs.NodeTool@0This is the specific task’s ID.NodeToolis the task that downloads and configures Node.js on the VM.@0indicates the Major Version of the task. If Microsoft releases a breaking change to how this task works, they would releaseNodeTool@1. Using@0locks your pipeline into version 0, ensuring an update doesn’t randomly break your build.
inputs:The parameters this specific task requires. Here, it needs aversionSpec, and we pass it our custom variable$(node_version).
YAML
- script: |
npm install
displayName: 'Install dependencies'
script:A raw command-line instruction. If you can type it in a Linux terminal, you can put it in ascriptstep.|(Pipe character) In YAML, this means “multi-line string.” It allows you to write multiple terminal commands on separate lines below it without breaking the YAML formatting.
e. Artifacts (Passing Data Between Machines)
YAML
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(System.DefaultWorkingDirectory)/$(build_folder)'
artifact: 'drop'
publishLocation: 'pipeline'
PublishPipelineArtifact@1Because the VM is destroyed after the job finishes, any files you built will be lost. This task zips up your files and saves them to Azure DevOps storage before the machine turns off.$(System.DefaultWorkingDirectory)This is a built-in system variable. You didn’t declare it. Azure DevOps automatically creates this variable to point to the exact folder on the VM where it downloaded your Git repository.artifact: 'drop'We are naming our zipped package “drop” (a traditional Microsoft term for a software release package).
f. The Deploy Stage
YAML
- stage: Deploy
dependsOn: Build
condition: succeeded()
jobs:
- job: DeployJob
steps:
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'current'
artifactName: 'drop'
targetPath: '$(System.DefaultWorkingDirectory)/drop'
dependsOn: BuildTells Azure not to start this stage until theBuildstage is entirely finished.condition: succeeded()A safety check. If a test failed or the build crashed, the pipeline stops and skips this stage entirely.DownloadPipelineArtifact@2BecauseDeployJobmight run on a brand new, empty VM, this task reaches into Azure DevOps storage, grabs the “drop” package we made earlier, and unzips it onto this new machine.
4. Key Troubleshooting Lessons from the Trenches
While setting this up, you might hit a few common roadblocks. Here is how to conquer them:
- The
npm ciLockfile Error: If your pipeline throws an error statingnpm ci can only install with an existing package-lock.json, it means your repository lacks a deterministic lockfile.- The Fix: Run
npm installlocally, and commit yourpackage-lock.json, or swap the script command tonpm installif you prefer dynamic dependency resolution.
- The Fix: Run
- Missing Test Scripts: If your pipeline fails because
npm run testcan’t find a test script, either add your testing framework script topackage.jsonor safely comment out the test step until your test suite is ready. - Understanding Task Versions (
@0,@1,@2): The number following the task name (likeDownloadPipelineArtifact@2) denotes the major version of the task itself, completely independent of how many times you’ve run your pipeline. Using version-pinned tasks ensures updates from Microsoft won’t randomly break your production builds.
5. Pro-Tip for Azure Portal Integration: Choose “Other”
When provisioning an Azure Static Web App to match this pipeline, select Other instead of Azure DevOps in the Azure Portal wizard.
Why? Selecting “Azure DevOps” forces Microsoft’s Oryx build engine to take over, injecting automated files that clash with your custom multi-year pipeline strategy. Choosing Other provides a raw Deployment Token. Plugging that token into your Azure DevOps secure variables—combined with skip_app_build: true—gives you absolute control over your build artifacts and deployment lifecycle.
Happy pipeline building! Keep iterating, secure your main branches with build validations, and map your commits back to Azure Boards for end-to-end traceability.
