Build a CI/CD Pipeline with Azure DevOps

Build a multistage YAML pipeline that auto-deploys a Node.js app to Azure.

Introduction

30 Second Summary

Imagine you finish a feature, push your code, and it's live on the internet minutes later. No manual uploads. No crossing your fingers. That's the power of a CI/CD pipeline.

In this project, you will build a fully automated CI/CD pipeline using Azure DevOps. You'll take a simple Node.js web app from a code push all the way to a live deployment on Azure App Service.

What You'll Build

A multistage YAML pipeline that automatically builds and deploys a Node.js Express app to Azure App Service every time you push code to the main branch.

By the end of this project, you'll have:

  • A Node.js Express web app pushed to Azure Repos with Git version control.
  • A multistage YAML pipeline with separate Build and Deploy stages that run automatically on every push.
  • A live Azure App Service instance running your web app, with a verified service connection linking Azure DevOps to your Azure subscription.
  • A staging environment with a manual approval gate for production-grade deployment control (Secret Mission).

Are there any prerequisites?

None. This project walks you through installing every tool and creating every account from scratch. You just need a Mac and a web browser.

Set Up Your Tools and Accounts

A CI/CD pipeline automates building, testing, and deploying your code every time you push a change. Before you can build one, you need the right tools on your machine and accounts in the cloud.

In this step, you'll install Git, Node.js, and the Azure CLI on your Mac. You'll also create your free Azure account and set up an Azure DevOps organization and project.

In this step, get ready to:
  • Install Git, Node.js, and Azure CLI.
  • Create your Azure free account and Azure DevOps organization.
  • Create your Azure DevOps project with an empty repo.
Install Git, Node.js, and Azure CLI

All three tools install through Homebrew, the package manager for macOS. First, check whether you already have Homebrew installed.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check if Homebrew is installed by running:
brew --version

I see a version number

Homebrew is ready to go. Skip ahead to installing Git below.

Command not found

  • Install Homebrew by running this command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

What does this command do?

This downloads and runs Homebrew's official installer script. The installer will ask for your Mac password and confirmation before proceeding.

After the installation finishes, Homebrew may print instructions to add it to your PATH. Follow those instructions, then close and reopen Terminal before continuing.

Homebrew install failed?

  • Make sure your Mac is running macOS 13 or later.
  • If you see a permissions error, make sure you entered your Mac login password when prompted.
  • Help me troubleshoot Homebrew installation on macOS.
  • Install Git by running:
brew install git
  • Install Node.js 24 LTS by running:
brew install node@24

Seeing PATH instructions after the install?

Homebrew installs node@24 as a "keg-only" formula. This means it is not automatically available in your terminal.

After the install completes, Homebrew prints instructions to add node@24 to your PATH. Copy and run those commands, then close and reopen Terminal.

  • Install Azure CLI by running:
brew update && brew install azure-cli
  • Verify all three tools are installed by running each of these commands:
git --version
node --version
az version

What should I see?

  • git --version prints something like git version 2.x.x.
  • node --version prints something like v24.x.x.
  • az version prints a JSON block with "azure-cli": "2.85.0" or a similar version.

Seeing 'command not found'?

  • If node --version fails, you likely need to add node@24 to your PATH. Re-read the output from brew install node@24 for the exact commands.
  • If az version fails, try closing and reopening Terminal, then run the command again.
  • If any command still fails after reopening Terminal, re-run the brew install command for that tool.
  • Help me troubleshoot my Git, Node.js, or Azure CLI installation on macOS using Homebrew.
Create your Azure free account and Azure DevOps organization

You need two things in the cloud: an Azure subscription (to host your app) and an Azure DevOps organization (to run your pipeline).

  • Open https://azure.microsoft.com/free/ in your browser.
  • Click Start free and sign in with a Microsoft account (or create one).
  • Complete the sign-up form, including credit card verification.

Will I be charged?

Azure requires a credit card for identity verification, but the App Service F1 plan you'll use in this project is always free. You will not be charged unless you manually upgrade to a paid plan.

With your Azure account ready, sign in from the terminal so the Azure CLI can manage resources on your behalf.

  • Sign in to Azure CLI by running:
az login

What happens when I run this?

This command opens your default browser and asks you to sign in with your Microsoft account. After you authenticate, the terminal prints your subscription details. You can close the browser tab and return to Terminal.

  • Confirm the terminal output shows your subscription name and ID.

Login not working?

  • If the browser does not open automatically, copy the URL printed in the terminal and paste it into your browser manually.
  • Make sure you sign in with the same Microsoft account you used to create your Azure free account.
  • Help me troubleshoot az login on macOS.

Now set up your Azure DevOps organization. This is the workspace where your pipeline will live.

  • Open https://dev.azure.com in your browser.
  • Sign in with your Microsoft account.
  • Azure DevOps prompts you to create an organization. Give it any name you like and select a region close to you.
  • Click Continue to create the organization.

There is one more background task to complete. New Azure DevOps organizations need to request a free parallel job grant before they can use Microsoft-hosted build agents.

What is the parallel job grant?

Azure DevOps provides free cloud-hosted agents to run your pipeline, but new organizations need to request access first. Approval takes 2 to 5 business days.

If your grant has not been approved by the time you need to run your pipeline in Step 4, you will set up a self-hosted agent on your Mac as a workaround.

Create your Azure DevOps project

An Azure DevOps project contains your code repository, pipeline definitions, and deployment history. Everything for this project lives under one roof.

  • In Azure DevOps (dev.azure.com), click New project in the top right corner.
  • Enter cicd-pipeline-demo as the project name.
  • Set the visibility to Private.
  • Click Create.

Azure DevOps creates the project and drops you on the project dashboard.

  • Click Repos in the left sidebar.

You should see an empty Azure Repos page with clone instructions. This is where your app code will live in the next step.

Don't see the Repos page?

  • Make sure you clicked into the cicd-pipeline-demo project first. The Repos option only appears inside a project, not at the organization level.
  • Help me find the Repos section in my Azure DevOps project.

Your tools are installed, your accounts are live, and your Azure DevOps project is waiting for code. Next up, you'll build a Node.js app and push it to that empty repo.

Build and Push the Node.js App

Your Azure DevOps project is set up with an empty repository waiting for code. Before you can build a pipeline, you need something to deploy.

In this step, you'll create a simple Node.js Express web app and push it to Azure Repos. The app is intentionally minimal so you can focus on the pipeline, not the application code.

In this step, get ready to:
  • Create a Node.js Express web app on your machine.
  • Push the app to Azure Repos so your pipeline has code to build.
Create the Node.js Express app locally

Your app needs its own project folder on your Desktop. You'll initialize a Node.js project, install Express, and write a tiny server.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir cicd-pipeline-demo
cd cicd-pipeline-demo
  • Confirm the folder exists by running:
ls ~/Desktop

You should see cicd-pipeline-demo in the output.

  • Initialize a new Node.js project by running:
npm init -y

What does this command do?

The -y flag accepts all the default prompts automatically. This creates a package.json file that tracks your project's name, version, and dependencies.

You should see a confirmation in your terminal that package.json was created.

  • Install Express as a dependency by running:
npm install express@4.21.2

You should see output showing packages being added, ending with added followed by a package count.

Seeing an npm error?

  • Make sure you ran npm init -y first. Without a package.json, npm cannot track dependencies.
  • Check that you are inside the cicd-pipeline-demo folder by running pwd in your terminal.
  • Help me debug my npm install error.

Now you have a project folder with a package.json and Express installed. Time to write the app itself.

  • Create a new file called app.js inside your cicd-pipeline-demo folder. You can do this from the terminal by running:
touch app.js
  • Open app.js in your text editor and paste the following code:
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;

// Define the homepage route
app.get('/', (req, res) => {
  res.send('<h1>Hello from Azure DevOps CI/CD Pipeline!</h1><p>This app was automatically deployed using Azure Pipelines.</p>');
});

// Start the server
app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

What does this code do?

  • require('express') imports the Express framework and creates an app instance.
  • process.env.PORT || 3000 uses the port Azure App Service assigns, or defaults to 3000 locally.
  • app.get('/', ...) defines what the homepage shows when someone visits your app.
  • app.listen(port) starts the server and prints a confirmation message.
  • Save app.js.
  • Test the app locally by running:
node app.js

You should see Server running on port 3000 in your terminal. Open http://localhost:3000 in your browser to see the homepage.

  • Press Ctrl+C in the terminal to stop the server before continuing.

Don't see the message in your browser?

  • Make sure the terminal still shows Server running on port 3000. If the server crashed, check app.js for typos.
  • Make sure you're visiting http://localhost:3000 (not https).
  • Help me debug why my Express app isn't loading in the browser.

Awesome, I've got everything!

Great. Double check you've saved app.js before moving on.

I'd like to double check the full code

const express = require('express');
const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('<h1>Hello from Azure DevOps CI/CD Pipeline!</h1><p>This app was automatically deployed using Azure Pipelines.</p>');
});

app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

Next, you need to update package.json so Azure App Service knows how to start your app.

  • Open package.json in your text editor.
  • Replace the entire contents with the following:
{
  "name": "cicd-pipeline-demo",
  "version": "1.0.0",
  "description": "A simple Node.js app deployed via Azure DevOps CI/CD pipeline",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "4.21.2"
  }
}

Why replace the whole file?

The npm init -y command generated a default package.json with placeholder values. You need to make a few key changes.

  • The "start": "node app.js" script tells Azure App Service how to launch your app.
  • The "main": "app.js" entry point replaces the default index.js.
  • The pinned Express version ensures consistent installs across environments.
  • Save package.json.

Awesome, I've got everything!

Great. Make sure package.json is saved before moving on.

I'd like to double check the full code

{
  "name": "cicd-pipeline-demo",
  "version": "1.0.0",
  "description": "A simple Node.js app deployed via Azure DevOps CI/CD pipeline",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "4.21.2"
  }
}

One last file. You need a .gitignore to keep unnecessary files out of your repository.

  • Create the .gitignore file by running:
touch .gitignore
  • Open .gitignore in your text editor and paste the following:
node_modules/
*.zip
.env

What does this file do?

The .gitignore file tells Git which files to exclude from version control.

  • node_modules/ contains thousands of dependency files that can be reinstalled from package.json.
  • *.zip excludes build archives.
  • .env keeps environment secrets out of your repo.
  • Save .gitignore.
  • Confirm all your files are in place by running:
ls -a

You should see app.js, package.json, .gitignore, and a node_modules folder in the output.

Initialize Git and push to Azure Repos

Your app is ready locally. Now you need to push it to Azure Repos so your pipeline can access the code.

  • Initialize a new Git repository and create your first commit by running these commands:
git init
git add .
git commit -m "Initial commit"

What do these commands do?

  • git init turns your folder into a Git repository.
  • git add . stages all your files for the commit.
  • git commit -m "Initial commit" saves a snapshot of your project.

You should see output listing the files that were committed. The node_modules folder is excluded thanks to your .gitignore.

Now you need the remote URL from Azure Repos to connect your local repository to the cloud.

  • Open your browser and go to dev.azure.com.
  • Navigate to your cicd-pipeline-demo project.
  • Click Repos in the left sidebar.
  • Find the Clone section at the top of the page.
  • Click the copy button next to the HTTPS URL.

Pro tip: use HTTPS

The HTTPS clone URL works without any extra SSH key setup. Azure DevOps will prompt you for credentials when you push.

  • Back in your terminal, connect your local repo to Azure Repos by running:
git remote add origin [[AZURE_REPOS_URL="your Azure Repos HTTPS URL"]]
  • Push your code to Azure Repos by running:
git push -u origin main

What does the -u flag do?

The -u flag sets origin main as the default upstream branch. After this, you can just run git push without specifying the remote and branch every time.

Getting an authentication error?

  • Azure DevOps may open a browser window asking you to sign in. Complete the sign-in and the push will continue.
  • If you see fatal: remote origin already exists, you may have already added the remote. Run git remote -v to check the current remote URL.
  • If the push fails with error: src refspec main does not match any, your default branch might be called master instead. Run git branch -m master main to rename it, then try pushing again.
  • Help me fix my git push to Azure Repos.
  • Go back to Repos in your Azure DevOps browser tab.
  • Refresh the page.

You should see your app.js, package.json, and .gitignore files listed in the repository. No node_modules folder should appear.

Your app is now live in Azure Repos. Next up, you'll create the Azure App Service that will host your deployed app.

Create the Azure App Service

Your Node.js app is live in Azure Repos. Now it needs a home on the internet where users can actually access it.

Azure App Service is a managed hosting platform that runs web apps without you managing servers. In this step, you'll create an App Service instance and connect Azure DevOps to your Azure subscription so the pipeline can deploy automatically.

In this step, get ready to:
  • Create an Azure resource group, App Service plan, and web app using the Azure CLI.
  • Set up a service connection between Azure DevOps and your Azure subscription.
  • Verify that the App Service is running and ready for deployment.
Create a resource group and App Service plan

Azure organizes cloud resources into resource groups. A resource group acts as a container that holds related resources together, so you can manage and delete them as a unit.

  • Create a resource group called rg-cicd-demo by running this command:
az group create --name rg-cicd-demo --location eastus

You should see JSON output with "provisioningState": "Succeeded" confirming the resource group was created.

Seeing an authorization error?

Make sure you ran az login in the previous step and completed the browser authentication flow. If your session has expired, run az login again to re-authenticate.

Why is my az group create command failing?

An App Service plan defines the pricing tier and compute resources your web app runs on. The F1 SKU is the free tier, so you won't be charged.

  • Create a free Linux App Service plan by running this command:
az appservice plan create --name plan-cicd-demo --resource-group rg-cicd-demo --sku F1 --is-linux

You should see JSON output confirming the plan was created successfully.

Now create the web app itself. The app name must be globally unique across all of Azure, so include your name or initials to avoid conflicts.

  • Create the web app by running this command, replacing the app name with your own unique name:
az webapp create --name [[APP_NAME="cicd-demo-yourname"]] --resource-group rg-cicd-demo --plan plan-cicd-demo --runtime "NODE|24-LTS"

You should see a large block of JSON output containing your new web app's details.

Getting a name conflict error?

Your app name must be globally unique across all Azure customers. Try adding numbers or extra initials, like cicd-demo-jsmith-42.

Why is my az webapp create command failing with a conflict error?

Set up the service connection in Azure DevOps

Your pipeline needs permission to deploy to your Azure subscription. A service connection stores this authorization so the pipeline can reach Azure App Service without you manually approving every deployment.

  • In Azure DevOps, click the gear icon at the bottom left to open Project Settings.
  • In the left sidebar under Pipelines, select Service connections.
  • Click New service connection.
  • Select Azure Resource Manager, then click Next.
  • Select Service principal (automatic), then click Next.
  • Select your Azure subscription from the Subscription dropdown.
  • Select rg-cicd-demo from the Resource group dropdown.
  • Enter azure-connection in the Service connection name field.
  • Click Save.

What is a service connection?

A service connection is a secure link between Azure DevOps and an external service like Azure. It stores the credentials your pipeline uses to deploy code. Without it, the pipeline has no way to reach your Azure subscription.

Pro tip: match this name exactly

The service connection name azure-connection must match the azureSubscription value in your pipeline YAML later. If you choose a different name here, you'll need to update the YAML to match.

Don't see your subscription in the dropdown?

Make sure you're signed into Azure DevOps with the same Microsoft account you used to create your Azure free account. If you used a different account, sign out and sign back in with the correct one.

Why isn't my Azure subscription showing up in the service connection setup?

Verify the App Service is running

Your App Service is created, but no code has been deployed to it yet. You can confirm it's running by visiting its public URL.

  • Open your browser and visit your App Service URL:
https://[[APP_NAME="cicd-demo-yourname"]].azurewebsites.net

You should see a default Azure placeholder page. This confirms the App Service is running and ready to receive your app in the next step.

Page not loading?

The web app can take a minute or two to start up after creation. Wait a moment and refresh the page.

Double check that the app name in your URL matches exactly what you used in the az webapp create command. A single typo in the URL will give you a "not found" error.

Why isn't my Azure App Service URL loading?

Your App Service is live and your service connection is ready. Next up, you'll write the YAML pipeline that builds your code and deploys it to this App Service automatically.

Write the CI/CD Pipeline

Your Azure App Service is live and your service connection links Azure DevOps to your Azure subscription.

Now it's time to write the pipeline that automates everything. You'll define your CI/CD instructions in a YAML file that lives right in your repository.

In this step, get ready to:
  • Create a multistage YAML pipeline with Build and Deploy stages.
  • Configure the agent pool for running your pipeline.
  • Push the pipeline file and create the pipeline in Azure DevOps.
Create the pipeline YAML file

The pipeline file tells Azure DevOps exactly how to build and deploy your app. You'll define two stages: a Build stage that packages your app into a deployable artifact, and a Deploy stage that sends that artifact to App Service.

  • Create a new file called azure-pipelines.yml in your cicd-pipeline-demo folder.
  • Add the trigger and Build stage by pasting this code:
# Run the pipeline on every push to main
trigger:
  - main

stages:
  # Stage 1: Build the app and create a deployable artifact
  - stage: Build
    displayName: 'Build stage'
    jobs:
      - job: Build
        displayName: 'Build job'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          # Install the correct Node.js version
          - task: NodeTool@0
            inputs:
              versionSpec: '24.x'
            displayName: 'Install Node.js'

          # Pull in Express and other dependencies
          - script: npm install
            displayName: 'Install dependencies'

          # Zip the project files for deployment
          - task: ArchiveFiles@2
            inputs:
              rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
              includeRootFolder: false
              archiveType: 'zip'
              archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip'
              replaceExistingArchive: true
            displayName: 'Archive files'

          # Upload the zip as a pipeline artifact
          - publish: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
            artifact: drop
            displayName: 'Publish artifact'

What does this code do?

  • trigger: - main tells Azure DevOps to run this pipeline every time code is pushed to the main branch.
  • NodeTool@0 installs Node.js 24.x on the build agent so your app's dependencies can be installed.
  • npm install pulls in the Express dependency from your package.json.
  • ArchiveFiles@2 zips your entire project into a single file for transport between stages.
  • publish uploads the zip as a pipeline artifact called drop. The Deploy stage downloads this artifact later.

You won't see a change yet. The Deploy stage in the next chunk completes the file.

  • In azure-pipelines.yml, add the Deploy stage directly below the Build stage at the same indentation level:
  # Stage 2: Deploy the artifact to Azure App Service
  - stage: Deploy
    displayName: 'Deploy stage'
    dependsOn: Build
    condition: succeeded()
    jobs:
      - deployment: Deploy
        displayName: 'Deploy to Azure App Service'
        environment: 'production'
        pool:
          vmImage: 'ubuntu-latest'
        strategy:
          runOnce:
            deploy:
              steps:
                # Use the service connection to deploy to App Service
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'azure-connection'
                    appType: 'webAppLinux'
                    appName: '[[APP_NAME="your unique app name e.g. cicd-demo-yourname"]]'
                    package: '$(Pipeline.Workspace)/drop/$(Build.BuildId).zip'
                    runtimeStack: 'NODE|24-LTS'
                  displayName: 'Deploy to Azure App Service'

What does this code do?

  • dependsOn: Build ensures the Deploy stage only runs after the Build stage succeeds.
  • condition: succeeded() adds an extra safety check that the previous stage completed without errors.
  • deployment is a special job type designed for deployment scenarios. It tracks deployment history in the production environment.
  • AzureWebApp@1 deploys your zipped app to Azure App Service using the service connection you created in the previous step.

What if my service connection has a different name?

The azureSubscription value must match the exact name of the service connection you created in the previous step. If you named it something other than azure-connection, update the value in your YAML to match.

  • Save azure-pipelines.yml.

Seeing YAML errors in your editor?

  • YAML is sensitive to indentation. Make sure the - stage: Deploy line is at the same indentation level as - stage: Build (two spaces in from stages:).
  • Make sure you used spaces, not tabs. YAML does not allow tab characters for indentation.
  • Check that all string values are wrapped in single quotes, like 'ubuntu-latest'.

Help me fix YAML syntax errors in my azure-pipelines.yml file.

Awesome, I've got everything!

Great. Double check you've saved azure-pipelines.yml.

I'd like to double check the full code

# Run the pipeline on every push to main
trigger:
  - main

stages:
  # Stage 1: Build the app and create a deployable artifact
  - stage: Build
    displayName: 'Build stage'
    jobs:
      - job: Build
        displayName: 'Build job'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          # Install the correct Node.js version
          - task: NodeTool@0
            inputs:
              versionSpec: '24.x'
            displayName: 'Install Node.js'

          # Pull in Express and other dependencies
          - script: npm install
            displayName: 'Install dependencies'

          # Zip the project files for deployment
          - task: ArchiveFiles@2
            inputs:
              rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
              includeRootFolder: false
              archiveType: 'zip'
              archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip'
              replaceExistingArchive: true
            displayName: 'Archive files'

          # Upload the zip as a pipeline artifact
          - publish: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
            artifact: drop
            displayName: 'Publish artifact'

  # Stage 2: Deploy the artifact to Azure App Service
  - stage: Deploy
    displayName: 'Deploy stage'
    dependsOn: Build
    condition: succeeded()
    jobs:
      - deployment: Deploy
        displayName: 'Deploy to Azure App Service'
        environment: 'production'
        pool:
          vmImage: 'ubuntu-latest'
        strategy:
          runOnce:
            deploy:
              steps:
                # Use the service connection to deploy to App Service
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'azure-connection'
                    appType: 'webAppLinux'
                    appName: '[[APP_NAME="your unique app name e.g. cicd-demo-yourname"]]'
                    package: '$(Pipeline.Workspace)/drop/$(Build.BuildId).zip'
                    runtimeStack: 'NODE|24-LTS'
                  displayName: 'Deploy to Azure App Service'
Configure the agent pool

Azure DevOps needs a machine (called an agent) to run your pipeline. Microsoft offers hosted agents in the cloud, but new organizations need to request access first.

Back in Step 1, you submitted a request at https://aka.ms/azpipelines-parallelism-request. Depending on whether that request has been approved, pick the right tab below.

My grant is approved

Your pipeline YAML already uses vmImage: 'ubuntu-latest' in both stages. This tells Azure DevOps to run each stage on a Microsoft-hosted Ubuntu agent.

No changes needed. You're ready to push the pipeline file.

I'm still waiting for the grant

No worries. You can set up a self-hosted agent on your Mac as a workaround. This lets your own machine run the pipeline instead of a Microsoft-hosted agent.

  • In Azure DevOps, click your user icon in the top right corner.
  • Select Personal access tokens.
  • Click New Token.
  • Give it a name like agent-setup.
  • Under Scopes, select Agent Pools (Read & manage).
  • Click Create and copy the token. Save it somewhere safe. You will not be able to see it again.

Now download and configure the agent.

  • In Azure DevOps, click the gear icon at the bottom left to open Project Settings.
  • Select Agent pools under Pipelines.
  • Click the Default pool.
  • Click New agent.
  • Select the macOS tab and follow the download instructions.
  • Open your terminal and extract the downloaded agent by running:
mkdir myagent && cd myagent
tar zxvf ~/Downloads/vsts-agent-osx-*.tar.gz
  • Configure the agent by running:
./config.sh

What will the config script ask?

  • Server URL: enter https://dev.azure.com/your-organization-name.
  • Authentication type: press Enter to accept the default (PAT).
  • Personal access token: paste the token you copied earlier.
  • Agent pool: press Enter to accept the default (Default).
  • Agent name: press Enter to accept the default.
  • Start the agent by running:
./run.sh

You should see a message saying the agent is listening for jobs. Keep this terminal window open while your pipeline runs.

Now update your YAML to use the self-hosted agent instead of the Microsoft-hosted one.

  • In azure-pipelines.yml, find this line inside the Build stage:
        pool:
          vmImage: 'ubuntu-latest'
  • Replace it with:
        pool:
          name: Default
  • Repeat this change for the Deploy stage as well. Both stages need to use name: Default.
  • Save azure-pipelines.yml.

Pro tip: switch back later

Once your parallel job grant is approved, you can switch back to vmImage: 'ubuntu-latest' and stop running the self-hosted agent.

Commit and push the pipeline file

The pipeline file is ready. Now you need to push it to Azure Repos and tell Azure DevOps to use it.

  • Stage, commit, and push the pipeline file by running these commands:
git add azure-pipelines.yml
git commit -m "Add CI/CD pipeline"
git push

Push rejected or authentication error?

  • If you see an authentication prompt, use the same credentials you used when pushing in Step 2.
  • If the push is rejected, make sure you're on the main branch by running git branch.

Help me fix a git push error to Azure Repos.

Now create the pipeline in Azure DevOps so it knows to use your YAML file.

  • In Azure DevOps, click Pipelines in the left sidebar.
  • Click New pipeline.
  • Select Azure Repos Git as your code source.
  • Select your cicd-pipeline-demo repository.
  • Select Existing Azure Pipelines YAML file.
  • In the Path dropdown, choose /azure-pipelines.yml from the main branch.
  • Review the YAML on the next screen.
  • Click Run.

Why select an existing YAML file?

Azure DevOps can generate a starter YAML file for you, but you already wrote your own. Selecting Existing Azure Pipelines YAML file tells Azure DevOps to use the pipeline definition you pushed to the repo.

Don't see your YAML file in the dropdown?

  • Make sure the branch is set to main.
  • Verify your push succeeded by checking Azure Repos in your browser. You should see azure-pipelines.yml listed in your repository files.

Help me find my azure-pipelines.yml file when creating a new pipeline in Azure DevOps.

Your pipeline is now running in Azure DevOps. Next up, you'll watch it build and deploy your app live.

Verify the Full CI/CD Loop

Your pipeline YAML is committed and the first run is underway. Everything you configured across the previous steps is about to come together.

In this step, you'll watch both pipeline stages execute, confirm your app is live on Azure App Service, and then push a real code change to prove the full CI/CD loop works end to end.

In this step, get ready to:
  • Watch both pipeline stages complete successfully.
  • Verify your app is live on Azure App Service.
  • Push a code change and confirm it auto-deploys.
Watch the pipeline run

The pipeline you triggered in the previous step has two stages. The Build stage installs dependencies and packages your app into a zip artifact. The Deploy stage takes that artifact and pushes it to Azure App Service.

  • In Azure DevOps, click Pipelines in the left sidebar.
  • Click into the running pipeline to open its details.
  • Click into the latest run to see the stage view.

You'll see two stages displayed as connected boxes: Build stage and Deploy stage.

  • Click on the Build stage box to expand its logs.
  • Watch the build steps complete one by one: Install Node.js, Install dependencies, Archive files, and Publish artifact.

Each step shows a green checkmark when it completes successfully.

  • Once the Build stage finishes, click on the Deploy stage box to expand its logs.
  • Watch the Deploy to Azure App Service task complete.

What if the pipeline asks for permission?

The first time your pipeline runs, Azure DevOps may ask you to authorize the service connection. If you see a Permit button on the Deploy stage, click it to allow the pipeline to use your azure-connection service connection.

This only happens once. Future runs will use the permission you granted here.

Pipeline stuck or failing?

  • If the pipeline is queued but not starting, your parallel job grant may not be approved yet. Switch to a self-hosted agent by changing vmImage: 'ubuntu-latest' to name: Default in both pool sections of your YAML.
  • If the Build stage fails at Install dependencies, check that your package.json has express listed under dependencies.
  • If the Deploy stage fails, make sure the azureSubscription value in your YAML matches the service connection name you created in the previous step.
  • My Azure DevOps pipeline is failing or stuck in the queue. Help me debug it.
Verify the live deployment

Both stages are green. Your pipeline has packaged your code and deployed it to Azure App Service. Time to see it live.

  • Open a new browser tab.
  • Paste your app's URL into the address bar:
https://[[APP_NAME="your-app-name"]].azurewebsites.net

You should see your Express app's homepage with the message you defined in app.js.

What if I still see the default Azure page?

It can take a minute or two for the deployment to finish propagating. Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).

If it still shows the default page after a few minutes, go back to your pipeline run in Azure DevOps and confirm the Deploy stage completed with a green checkmark.

Seeing an application error page?

  • Check that your package.json has a start script set to node app.js. Azure App Service uses this script to launch your app.
  • Make sure your app.js uses process.env.PORT for the port. Azure App Service assigns a dynamic port, so hardcoding a port number will cause the app to crash.
  • My app deployed but shows an application error. Help me troubleshoot.
Make a code change and watch it auto-deploy

Seeing your app live is great. But the real power of CI/CD is what happens next.

Your pipeline trigger is set to the main branch. Every time you push a change to main, Azure Pipelines will automatically build and deploy the update. Let's prove it.

  • In app.js, find the res.send line inside your app.get route.
  • Find this line:
  res.send('<h1>Hello World</h1>');
  • Replace that entire line with the following:
  res.send('<h1>Hello from Azure DevOps CI/CD Pipeline!</h1><p>This app was automatically deployed using Azure Pipelines.</p>');

Why this specific change?

The new message confirms two things at once. First, that your app is running. Second, that it was deployed automatically through the pipeline. When you see this message on the live URL, you'll know the entire CI/CD loop is working.

  • Save app.js.

Awesome, I've got everything!

Double check you've saved your file before moving on to the commit.

I'd like to double check the full code

Here is the complete app.js file after the update.

const express = require('express');
const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('<h1>Hello from Azure DevOps CI/CD Pipeline!</h1><p>This app was automatically deployed using Azure Pipelines.</p>');
});

app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});
  • Commit and push your change by running these commands:
git add .
git commit -m "Update homepage"
git push
  • Go back to Pipelines in Azure DevOps.
  • Watch for a new pipeline run to appear automatically.

The push to main matched the trigger in your YAML, so Azure Pipelines kicked off a new run without you doing anything.

  • Click into the new run and wait for both stages to complete with green checkmarks.
  • Once the Deploy stage finishes, go back to your browser tab with the app URL.
  • Refresh the page with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).

You should see your updated message: "Hello from Azure DevOps CI/CD Pipeline!" with the paragraph text below it.

What just happened?

You just completed a full CI/CD loop. You changed code locally, pushed it, and a pipeline automatically built, packaged, and deployed your update to a live URL.

This is exactly how professional teams ship software. No manual uploads. No FTP. Just push and go.

Don't see the updated message?

  • Confirm the new pipeline run completed. Go to Pipelines and check that the latest run has green checkmarks on both stages.
  • Make sure you saved app.js before committing. If the file wasn't saved, git add . would have pushed the old version.
  • Try a hard refresh to clear browser cache. Cached pages can show the old version even after a successful deploy.
  • I pushed a code change but my live site still shows the old version. Help me figure out why.

Secret mission

Add a Staging Environment with Manual Approval

Real teams never deploy straight to production. In this challenge, you'll add a staging environment with a manual approval gate to your pipeline. Your code will have to pass through staging and get your sign-off before it reaches your users.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Azure App Service F1 free tier and the Azure DevOps free tier, so there are no ongoing costs.

Resources you used:

  • Azure resource group rg-cicd-demo (contains the App Service plan and web app).
  • App Service plan plan-cicd-demo (F1 free tier).
  • Web app <your-app-name> on Azure App Service.
  • Azure DevOps organization and project cicd-pipeline-demo.
  • Service connection azure-connection in Azure DevOps.
  • Local project directory cicd-pipeline-demo on your machine.
  • Self-hosted agent on your Mac (if you configured one).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment more with your pipeline.

  • Your App Service plan is on the F1 free tier, so it costs nothing to keep running.
  • Your Azure DevOps organization is free for up to 5 users.
  • Your pipeline will continue to trigger automatically on every push to main.

Pause - I'll come back to this later

Stop your App Service to free up resources while keeping everything intact for later.

  • Stop the web app so it is not running:
az webapp stop --name <your-app-name> --resource-group rg-cicd-demo
  • If you set up a self-hosted agent, stop the agent process in your terminal with Ctrl+C.
  • Your Azure DevOps project, pipeline, and Azure Repos code will remain available.
  • When you are ready to resume, start the app again:
az webapp start --name <your-app-name> --resource-group rg-cicd-demo

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Delete the entire Azure resource group. This removes the App Service plan and web app in one command:
az group delete --name rg-cicd-demo --yes --no-wait
  • Delete the Azure DevOps project by going to Project Settings in the bottom left of your Azure DevOps portal. Scroll down and click Delete under the project overview.
  • If you set up a self-hosted agent, stop the agent process and remove the agent folder from your Mac.
  • Delete the local project directory:
rm -rf ~/cicd-pipeline-demo
  • If you want to remove the Azure DevOps organization entirely, go to Organization settings at dev.azure.com and select Delete.

Mission accomplished

That's a Wrap!

Nice work! You just built a fully automated CI/CD pipeline from scratch using Azure DevOps. Every time you push code to main, your pipeline builds, packages, and deploys your app to a live URL without you lifting a finger.

Here's what you learned:

  • Created a multistage YAML pipeline with Build and Deploy stages that automatically triggers on every push to main.
  • Set up an Azure App Service and a service connection to give Azure DevOps permission to deploy directly to your Azure subscription.
  • Verified the complete CI/CD loop by pushing a code change and watching it automatically build, deploy, and go live at your app's URL.
  • Added a staging environment with a manual approval gate so deployments must be reviewed before reaching production.

Ready to quiz yourself?