Build a CI/CD Pipeline with Azure DevOps

Automate build and test of a Node.js app using Azure Pipelines.

Introduction

30 Second Summary

Every time a developer pushes code, someone has to check it works, package it up, and get it ready to ship. Doing that manually is slow and error-prone.

In this project, you will build an automated CI/CD pipeline in Azure DevOps that builds, tests, and packages a Node.js web application every time you push code.

What You'll Build

A multi-stage YAML pipeline that automatically installs dependencies, runs tests, and produces a deployable artifact whenever code is pushed to your repository.

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

  • A Node.js web application stored in Azure Repos with full version control.
  • A multi-stage YAML pipeline that automatically builds and tests your code on every push to main.
  • Build artifacts packaged and ready for deployment, plus a pipeline status badge on your repository.
  • Secret Mission: Deploy your app to a live Azure App Service, creating a fully end-to-end CI/CD pipeline from code commit to live website.

Are there any prerequisites?

You should have a basic understanding of Git (commit, push, pull). You also need a Microsoft account (personal or work/school) to sign up for Azure DevOps. No credit card is required.

Set Up Azure DevOps and Git

Before you can build an automated pipeline, you need a place to store your code and a service to run your builds.

In this step, you will create an Azure DevOps organization and project to host everything. You will also install and configure Git, the version control tool that connects your local machine to Azure Repos.

In this step, get ready to:
  • Create an Azure DevOps organization and project.
  • Request free parallelism for pipeline runs (if needed).
  • Install and configure Git on your machine.
Create your Azure DevOps organization and project

Azure DevOps is a suite of services for planning, building, and shipping software. You need an organization (which groups your projects) and a project (which holds your code, pipelines, and boards).

  • Open your web browser and navigate to dev.azure.com.
  • Sign in with your Microsoft account (personal or work/school). If you do not have one, create a free Microsoft account when prompted.

What is an Azure DevOps organization?

An organization is the top-level container in Azure DevOps. It groups related projects together and defines your billing boundary. You access your organization at https://dev.azure.com/{Your_Organization}.

  • If Azure DevOps prompts you to create an organization, enter a name (your name or company works fine) and select your preferred hosting region.
  • Once you are inside your organization, click New project in the top-right area of the Projects page.
  • Enter nodejs-cicd-demo as the project name.
  • Under Version control, select Git.
  • Set Visibility to Private.
  • Click Create.

Azure DevOps displays your new project's welcome page. You should see the project name nodejs-cicd-demo in the top-left corner.

Why Azure Repos instead of GitHub?

Using Azure Repos keeps everything inside the Azure DevOps ecosystem. This means no extra OAuth setup or GitHub app installation. For learning Azure DevOps specifically, it provides the most cohesive experience.

Don't see the New project button?

You must be the organization owner or have the "Create new projects" permission. If you just created the organization, you are the owner by default. Try refreshing the page.

help me troubleshoot why I can't see the New project button in Azure DevOps

Request free parallelism (if needed)

Azure DevOps uses agents to run your pipeline. New organizations sometimes start with zero Microsoft-hosted parallel jobs, which means your pipeline cannot run until you request a free grant.

  • In the bottom-left corner of Azure DevOps, click Organization settings.
  • In the left sidebar, expand Pipelines and select Parallel jobs.
  • Check the Microsoft-hosted row. If it shows 0 parallel jobs, you need to request a free grant.

✔️ I see 1 or more parallel jobs

You are all set. Your organization already has the free parallelism grant. Skip ahead to the next substep.

ⓧ I see 0 parallel jobs

You need to request the free grant. This is a one-time process for new organizations.

How long does the parallelism grant take?

Processing takes 4-5 business days. In the meantime, you can still complete Steps 2 and 3 (creating code and writing the pipeline YAML). When the grant arrives, your pipeline will be ready to run.

Alternatively, you can set up a self-hosted agent on your own machine to run pipelines immediately without waiting for the grant.

What is a parallel job?

A parallel job is a pipeline run that executes on an agent. The free tier gives you 1 Microsoft-hosted parallel job with 1,800 minutes per month. That is more than enough for this project.

Install and verify Git

Git is the version control system that lets you push code from your machine to Azure Repos. You need it installed locally to clone, commit, and push.

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

✔️ I see a version number

Git is installed and ready to go. You should see output like git version 2.x.x. Move on to the Git configuration step below.

ⓧ Command not found

Git is not installed on your machine. Follow these steps to install it.

  • Open https://git-scm.com in your browser.
  • Download the installer for your operating system.
  • Run the installer. On Windows, make sure the option to add Git to your PATH is selected during installation.
  • Close and reopen your terminal after installation.
  • Run git --version again to confirm it works.

Still seeing command not found after installing?

Make sure you closed and reopened your terminal after installation. On Windows, Git must be added to your system PATH during the install process. On macOS, you may see a prompt to install Command Line Developer Tools. Accept it and try again.

help me troubleshoot why git --version is not working after I installed Git

Now configure your Git identity. This attaches your name and email to every commit you make.

  • Set your name by running this command:
git config --global user.name "[[YOUR_NAME="your full name"]]"
  • Set your email by running this command:
git config --global user.email "[[YOUR_EMAIL="your email address"]]"

What do these commands do?

The git config --global commands save your identity settings to a global config file on your machine. Every Git commit you make from now on includes this name and email. Use the same email you used to sign into Azure DevOps for a seamless experience.

  • Verify your configuration by running:
git config --global --list

You should see your user.name and user.email in the output.

Not seeing your name or email in the output?

Double-check you included the --global flag when running both config commands. Without it, the setting only applies to a single repository.

help me fix my git config so my name and email appear globally

Your Azure DevOps project is ready and Git is configured. Next up, you will create a Node.js application and push it into Azure Repos.

Create Your Node.js Application in Azure Repos

Your Azure DevOps environment is ready to go. Now it's time to give your pipeline something to build.

Every CI/CD pipeline needs source code to work with. In this step, you'll create a simple Node.js web application with automated tests and push everything to Azure Repos so your pipeline has code to build and test.

In this step, get ready to:
  • Clone the empty Azure Repos repository to your local machine.
  • Write a Node.js web application with an Express server and automated tests.
  • Push your code to Azure Repos and verify it appears in the file browser.
Clone the empty repo and create the app structure

When you created your project, Azure DevOps automatically created an empty Git repository for it. You need to clone this repository to your local machine so you can add files to it.

  • Return to your Azure DevOps project in the browser.
  • Click Repos in the left navigation menu.
  • On the repository page, find the Clone button in the top right area.
  • Click Clone and copy the HTTPS clone URL.

What does the clone URL look like?

Your clone URL follows the format https://dev.azure.com/{yourorganization}/nodejs-cicd-demo/_git/nodejs-cicd-demo. The exact URL depends on your organization name.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your repository and move into the project folder by running these commands:
git clone [[REPO_URL="your HTTPS clone URL from Azure Repos"]]
cd nodejs-cicd-demo

What do these commands do?

  • `git clone` downloads the remote repository to your local machine, creating a folder named nodejs-cicd-demo.
  • `cd nodejs-cicd-demo` moves your terminal into that folder so you can work inside it.

Signing in to Azure DevOps from Git

When you clone for the first time, Git Credential Manager prompts you to sign in. A browser window may open asking you to authenticate with your Microsoft account. Sign in with the same account you used for Azure DevOps.

Once authenticated, your credentials are cached so future Git operations connect automatically.

Clone failing with an authentication error?

  • Make sure you copied the HTTPS URL (not SSH) from the Clone button.
  • Confirm you're signing in with the same Microsoft account you used to create your Azure DevOps organization.
  • If you see a 403 error, your cached credentials may be stale. Clear them and try again.

Help me troubleshoot a git clone authentication error with Azure DevOps

  • Create the three application files by running:
touch package.json index.js test.js
  • Confirm the files exist by running:
ls

You should see index.js, package.json, and test.js in the output.

Write the application code

Your app has three files: a package manifest that declares dependencies, an Express web server, and a test suite. You'll add code to each one.

  • Open package.json in a text editor and paste the following code:
{
  "name": "nodejs-cicd-demo",
  "version": "1.0.0",
  "description": "A simple Node.js app for learning Azure DevOps CI/CD",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "node --test test.js"
  },
  "dependencies": {
    "express": "4.21.2"
  }
}

What does this file do?

  • `name` and `version` identify your project.
  • `scripts.start` defines how to launch the app with node index.js.
  • `scripts.test` runs your test suite using Node.js's built-in test runner (node --test test.js).
  • `dependencies` declares Express.js version 4.21.2 as the web framework your app uses.
  • Save package.json.
  • Open index.js in your text editor and paste the following code:
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({ message: 'Hello from Azure DevOps CI/CD!', status: 'healthy' });
});

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

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

module.exports = app;

What does this code do?

  • It creates an Express web server that listens on port 3000 (or a port set by the environment).
  • The / route returns a JSON response with a greeting and a healthy status.
  • The /health route returns an ok status with a timestamp, useful for monitoring.
  • The if (require.main === module) block starts the server only when the file is run directly. This lets the test file import the app without starting a server.
  • `module.exports = app` exports the app so test.js can import and test it.
  • Save index.js.
  • Open test.js in your text editor and paste the following code:
const { describe, it } = require('node:test');
const assert = require('node:assert');
const http = require('http');
const app = require('./index');

describe('App Tests', () => {
  let server;

  it('should return a healthy status on GET /', (t, done) => {
    server = app.listen(0, () => {
      const port = server.address().port;
      http.get(`http://localhost:${port}/`, (res) => {
        let data = '';
        res.on('data', (chunk) => { data += chunk; });
        res.on('end', () => {
          const body = JSON.parse(data);
          assert.strictEqual(res.statusCode, 200);
          assert.strictEqual(body.status, 'healthy');
          server.close(done);
        });
      });
    });
  });

  it('should return ok on GET /health', (t, done) => {
    server = app.listen(0, () => {
      const port = server.address().port;
      http.get(`http://localhost:${port}/health`, (res) => {
        let data = '';
        res.on('data', (chunk) => { data += chunk; });
        res.on('end', () => {
          const body = JSON.parse(data);
          assert.strictEqual(res.statusCode, 200);
          assert.strictEqual(body.status, 'ok');
          server.close(done);
        });
      });
    });
  });
});

What does this test file do?

  • It uses Node.js's built-in test runner (the node:test module) so you don't need any extra test frameworks.
  • The first test starts the server on a random port, sends a GET request to /, and verifies the response has status code 200 and body.status equals healthy.
  • The second test does the same for the /health endpoint, checking that body.status equals ok.
  • Using app.listen(0) picks a random available port, so tests never conflict with other processes.
  • Save test.js.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Here are all three files for comparison.

package.json

{
  "name": "nodejs-cicd-demo",
  "version": "1.0.0",
  "description": "A simple Node.js app for learning Azure DevOps CI/CD",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "node --test test.js"
  },
  "dependencies": {
    "express": "4.21.2"
  }
}

index.js

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

app.get('/', (req, res) => {
  res.json({ message: 'Hello from Azure DevOps CI/CD!', status: 'healthy' });
});

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

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

module.exports = app;

test.js

const { describe, it } = require('node:test');
const assert = require('node:assert');
const http = require('http');
const app = require('./index');

describe('App Tests', () => {
  let server;

  it('should return a healthy status on GET /', (t, done) => {
    server = app.listen(0, () => {
      const port = server.address().port;
      http.get(`http://localhost:${port}/`, (res) => {
        let data = '';
        res.on('data', (chunk) => { data += chunk; });
        res.on('end', () => {
          const body = JSON.parse(data);
          assert.strictEqual(res.statusCode, 200);
          assert.strictEqual(body.status, 'healthy');
          server.close(done);
        });
      });
    });
  });

  it('should return ok on GET /health', (t, done) => {
    server = app.listen(0, () => {
      const port = server.address().port;
      http.get(`http://localhost:${port}/health`, (res) => {
        let data = '';
        res.on('data', (chunk) => { data += chunk; });
        res.on('end', () => {
          const body = JSON.parse(data);
          assert.strictEqual(res.statusCode, 200);
          assert.strictEqual(body.status, 'ok');
          server.close(done);
        });
      });
    });
  });
});
Push your code to Azure Repos

Your application is ready locally. Now you'll commit all three files and push them to your remote repository so Azure DevOps can access them.

  • Stage all files, commit, and push to Azure Repos by running these commands:
git add .
git commit -m "Initial commit: Node.js app with tests"
git push origin main

What do these commands do?

  • `git add .` stages all new and changed files for commit.
  • `git commit -m "..."` saves a snapshot of your staged files with a descriptive message.
  • `git push origin main` uploads your commit to the remote repository on Azure Repos.
  • Return to your Azure DevOps project in the browser.
  • Click Repos in the left navigation menu.
  • Click Files to see the file browser.

You should see index.js, package.json, and test.js listed in the repository file browser.

Don't see your files in Azure Repos?

  • Make sure git push completed without errors in your terminal. Look for a success message mentioning the main branch.
  • If you see a rejection error, your local branch might be named differently. Run git branch to check. If it says master instead of main, rename it with git branch -M main and push again.
  • Refresh the Azure Repos page in your browser. Changes may take a moment to appear.

Help me troubleshoot pushing code to Azure Repos

Your application code is safely stored in Azure Repos. Next up, you'll create a YAML pipeline that automatically builds and tests this code every time you push a change.

Create Your First YAML Pipeline

Your Node.js application and tests are stored in Azure Repos, ready to be built. The missing piece is automation. Right now, you would have to manually install dependencies and run tests every time you change something.

Azure Pipelines lets you define your entire build process as a YAML file that lives alongside your code. In this step, you'll create a pipeline that automatically builds and packages your app every time you push to main.

In this step, get ready to:
  • Create a new pipeline linked to your Azure Repos repository.
  • Write a YAML configuration that installs dependencies and packages your app as an artifact.
  • Run the pipeline and verify it completes successfully.
Navigate to Pipelines and create a new pipeline

Your project in Azure DevOps has a dedicated Pipelines section where you define and manage all your automated workflows.

  • In your Azure DevOps project, select Pipelines from the left navigation menu.
  • Select Create Pipeline (or New pipeline if you see that option instead).
  • On the "Where is your code?" screen, select Azure Repos Git.
  • Select your nodejs-cicd-demo repository from the list.

Why Azure Repos Git?

This option tells Azure Pipelines to look for your code in the same Azure DevOps project. Since you pushed your Node.js app to Azure Repos in the previous step, selecting this connects the pipeline directly to that repository.

Configure the pipeline with a Starter pipeline

Azure DevOps provides templates to help you get started quickly. You'll start with the simplest option and replace it with your own custom configuration.

  • On the Configure tab, select Starter pipeline.
  • This opens the YAML editor with a basic template. Select all the existing content in the editor and delete it.
  • Paste the following pipeline configuration into the empty editor:
trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

stages:
  - stage: Build
    displayName: 'Build Stage'
    jobs:
      - job: BuildJob
        displayName: 'Install and Build'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '18.x'
            displayName: 'Install Node.js'

          - script: npm install
            displayName: 'Install dependencies'

          - task: ArchiveFiles@2
            inputs:
              rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
              includeRootFolder: false
              archiveType: 'zip'
              archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip'
              replaceExistingArchive: true
            displayName: 'Archive application'

          - publish: $(Build.ArtifactStagingDirectory)
            artifact: drop
            displayName: 'Publish artifact'

What does this pipeline do?

This YAML defines a complete build workflow for your Node.js app. Here is what each section does:

  • trigger: - main tells Azure Pipelines to run this pipeline every time someone pushes code to the main branch.
  • pool: vmImage: 'ubuntu-latest' selects a Microsoft-hosted Linux virtual machine to run your build.
  • NodeTool@0 installs Node.js 18.x on the build agent so it can run your app.
  • npm install installs your project dependencies (Express.js) from package.json.
  • ArchiveFiles@2 zips your entire application into a single file for deployment.
  • publish saves the zipped file as a pipeline artifact called drop. This makes it available for download or use in later stages.

✔️ Awesome, I've got everything!

Great. Double check that your YAML is pasted correctly in the editor before moving on to save and run.

ⓧ I'd like to double check the full code

Here is the complete azure-pipelines.yml that should be in your editor:

trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

stages:
  - stage: Build
    displayName: 'Build Stage'
    jobs:
      - job: BuildJob
        displayName: 'Install and Build'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '18.x'
            displayName: 'Install Node.js'

          - script: npm install
            displayName: 'Install dependencies'

          - task: ArchiveFiles@2
            inputs:
              rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
              includeRootFolder: false
              archiveType: 'zip'
              archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip'
              replaceExistingArchive: true
            displayName: 'Archive application'

          - publish: $(Build.ArtifactStagingDirectory)
            artifact: drop
            displayName: 'Publish artifact'
Save and run your pipeline

When you save a pipeline in Azure DevOps, it commits the azure-pipelines.yml file directly to your repository and immediately triggers the first run.

  • Select Save and run in the top right corner of the YAML editor.
  • A dialog appears asking you to commit the new azure-pipelines.yml file. Leave the default commit message and select Save and run again.
  • Azure DevOps commits the file to your main branch and the pipeline starts running immediately.

You should see the pipeline run page load, showing a Build Stage with a spinning indicator. Click into the stage to watch each step execute in real time.

What should I see?

Each step shows a green checkmark as it completes. You'll see Install Node.js, Install dependencies, Archive application, and Publish artifact all finish in sequence.

The entire run typically takes 1-2 minutes. Once all steps succeed, the overall stage shows a green checkmark and the run summary shows a successful build.

Pipeline stuck or showing an error?

If you see a message about no hosted parallelism being purchased or granted, your organization needs a free parallelism grant. Submit a request at https://aka.ms/azpipelines-parallelism-request (processing takes 4-5 business days). Alternatively, set up a self-hosted agent to run pipelines immediately.

If you see a YAML syntax error, check that your indentation uses spaces (not tabs) and that each key is aligned correctly. YAML is strict about spacing.

Help me debug my pipeline error.

Your first pipeline is running. Next up, you'll explore the pipeline results and artifacts in detail, then trigger the pipeline again with a code change.

Understand Pipeline Stages and Artifacts

Your pipeline ran successfully in the previous step. Now it's time to understand what it actually produced.

Every pipeline run generates detailed logs and packaged files called artifacts. In this step, you'll dig into the run results, download your build output, and prove that the pipeline fires automatically whenever you push code.

In this step, get ready to:
  • Explore the pipeline run summary and step logs.
  • Download and verify your published build artifact.
  • Trigger a new pipeline run automatically by pushing a code change.
Explore the pipeline run results

Pipeline logs are your window into exactly what happened during a run. Each step produces its own log output, so you can pinpoint where things succeeded or failed.

  • In your Azure DevOps project, select Pipelines from the left navigation menu.
  • Click on your most recent pipeline run (it should show a green checkmark indicating success).
  • Select the Summary tab at the top of the run details page.
  • Click into the Build Stage to see the jobs within it.
  • Click the Install and Build job to see the individual step logs.
  • Click on each step (Install Node.js, Install dependencies, Archive application, Publish artifact) to expand its log output.

What should I see in the logs?

Each step has a green checkmark and a timestamp showing how long it took. The Install dependencies step shows npm downloading Express and its sub-dependencies. The Archive application step shows the ZIP file being created.

If any step had failed, it would show a red X and the pipeline would stop there. This is the core value of a pipeline: failures surface immediately instead of hiding until deployment.

Download and verify your build artifact

An artifact is a packaged file produced by a pipeline run. Your pipeline zips up your entire application and publishes it as an artifact named drop. This is what a deployment stage would use to push your app to a live server.

  • Return to the pipeline run Summary tab.
  • In the Related section, find and click the published artifact labeled 1 published.
  • Expand the drop folder to see the contents.
  • Click the download icon to download the ZIP file to your machine.
  • Unzip the downloaded file and confirm it contains your application files (package.json, index.js, test.js, and node_modules).

Why does the artifact matter?

The artifact is a snapshot of your application at the exact moment the pipeline ran. In a real deployment workflow, later pipeline stages download this artifact and deploy it to servers. This means you always deploy exactly what was built and tested, never a different version of the code.

Don't see the artifact link?

Make sure you are on the Summary tab of the pipeline run (not the pipeline list page). The artifact link appears in the Related section on the right side of the summary. If the pipeline run is still in progress, wait for it to complete.

Help me find my published artifact in Azure Pipelines.

Trigger the pipeline with a code change

The real power of a CI/CD pipeline is that it runs automatically. Your azure-pipelines.yml file has trigger: - main at the top, which means any push to the main branch triggers a new pipeline run. This is Continuous Integration in action.

  • In your terminal, open index.js in your code editor.
  • Find this line:
  res.json({ message: 'Hello from Azure DevOps CI/CD!', status: 'healthy' });
  • Change the message string to something different, for example 'Hello from my CI/CD pipeline!', so the line looks like this:
  res.json({ message: 'Hello from my CI/CD pipeline!', status: 'healthy' });
  • Save index.js.
  • Stage, commit, and push your change by running these commands:
git add .
git commit -m "Update response message"
git push origin main

What is Continuous Integration?

Continuous Integration (CI) means automatically building and testing your code every time a change is pushed. Instead of waiting days to discover that a change broke something, you find out within minutes. The faster you catch bugs, the easier they are to fix.

  • Return to Pipelines in your Azure DevOps project.
  • You should see a new pipeline run appear automatically, triggered by your push. It will show as In progress or Queued.
  • Wait for the run to complete with a green checkmark. Click into it and verify the Build stage succeeded.

Pipeline didn't trigger automatically?

Confirm your azure-pipelines.yml has trigger: - main at the top. If you pushed to a different branch, the trigger won't fire. Also check that your push succeeded by verifying the updated file appears in Repos > Files in Azure DevOps.

If you have 0 parallel jobs and haven't received your free parallelism grant yet, the run will remain queued indefinitely. Check your grant status or use a self-hosted agent.

Help me troubleshoot why my pipeline didn't trigger after pushing to main.

You just proved the core CI loop: push code, pipeline runs, results appear. Next up, you'll add a dedicated Test stage to your pipeline and display a status badge in your repository.

Add a Test Stage and Pipeline Status Badge

Your pipeline currently bundles everything into a single Build stage. In production, teams split their pipeline into distinct stages so that each responsibility is clear and failures are easy to pinpoint.

In this step, you'll add a dedicated Test stage that depends on Build succeeding. You'll also add a status badge to your repository so anyone can see your pipeline health at a glance.

In this step, get ready to:
  • Add a Test stage to your pipeline YAML.
  • Add a pipeline status badge to your README.
  • Verify the pipeline catches test failures and recovers.
Add a separate Test stage to your pipeline

Right now your Build stage installs dependencies, archives the application, and publishes the artifact. Testing belongs in its own stage so you can see exactly where a failure happens.

  • Open azure-pipelines.yml in your text editor.
  • Find the last line of the Build stage. It ends with:
          - publish: $(Build.ArtifactStagingDirectory)
            artifact: drop
            displayName: 'Publish artifact'
  • Add the following Test stage below the Build stage, keeping the same indentation level as the - stage: Build line:
  - stage: Test
    displayName: 'Test Stage'
    dependsOn: Build
    jobs:
      - job: TestJob
        displayName: 'Run Tests'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '18.x'
            displayName: 'Install Node.js'

          - script: npm install
            displayName: 'Install dependencies'

          - script: npm test
            displayName: 'Run tests'

What does this code do?

This defines a second stage called Test that only runs after Build succeeds.

  • dependsOn: Build creates a dependency. If Build fails, Test never runs.
  • The Test stage installs Node.js and dependencies fresh (each stage runs on a new agent), then executes npm test to run your test suite.
  • If any test assertion fails, the npm test script returns a non-zero exit code, which marks the stage as failed.
  • Save azure-pipelines.yml.

Why does the Test stage reinstall everything?

Each stage in Azure Pipelines runs on a fresh virtual machine. Nothing from the Build stage's file system carries over automatically. The Test stage needs its own npm install to have the dependencies available.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

␧ I'd like to double check the full code

trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

stages:
  - stage: Build
    displayName: 'Build Stage'
    jobs:
      - job: BuildJob
        displayName: 'Install and Build'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '18.x'
            displayName: 'Install Node.js'

          - script: npm install
            displayName: 'Install dependencies'

          - task: ArchiveFiles@2
            inputs:
              rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
              includeRootFolder: false
              archiveType: 'zip'
              archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip'
              replaceExistingArchive: true
            displayName: 'Archive application'

          - publish: $(Build.ArtifactStagingDirectory)
            artifact: drop
            displayName: 'Publish artifact'

  - stage: Test
    displayName: 'Test Stage'
    dependsOn: Build
    jobs:
      - job: TestJob
        displayName: 'Run Tests'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '18.x'
            displayName: 'Install Node.js'

          - script: npm install
            displayName: 'Install dependencies'

          - script: npm test
            displayName: 'Run tests'
Add a pipeline status badge to your README

A status badge is a small image that shows whether your pipeline is passing or failing. Adding one to your README gives instant visibility into your project's health.

  • Return to your Azure DevOps project in your browser.
  • Select Pipelines from the left navigation menu.
  • Select your pipeline from the list.
  • Click the three-dot menu (...) in the top-right corner of the pipeline page.
  • Select Status badge.
  • In the dialog that appears, copy the Sample markdown snippet to your clipboard.

What is this Markdown snippet?

The snippet is a Markdown image link that points to a live SVG badge hosted by Azure DevOps. It updates automatically every time your pipeline runs.

When you embed it in a README, anyone visiting your repo instantly sees whether the latest build passed or failed.

  • Create a new file called README.md in your project's root folder (the same folder as package.json).
  • Add the following content, pasting your copied badge snippet on the indicated line:
# nodejs-cicd-demo

[[STATUS_BADGE="paste your status badge Markdown snippet here"]]

A simple Node.js app for learning Azure DevOps CI/CD.

What does this code do?

The first line is your project title. The second non-blank line is your status badge (the Markdown you copied from Azure Pipelines). The last line is a short project description.

  • Save README.md.
  • Stage, commit, and push both files by running these commands in your terminal:
git add .
git commit -m "Add Test stage and README with status badge"
git push origin main
  • Return to Pipelines in Azure DevOps. You should see a new pipeline run triggered automatically.
  • Wait for the run to complete. You should see two stages (Build Stage and Test Stage) both with green checkmarks.

Pipeline stuck or failed?

  • If the Test stage fails with a "module not found" error, make sure the Test stage includes the npm install step before npm test.
  • If the YAML file shows a parsing error, check your indentation. Each - stage: block should align at 2 spaces under stages:.
  • Why is my Test stage failing even though tests pass locally?
Verify the full pipeline flow

A pipeline that only passes is not fully tested. You need to confirm it catches real failures too. You'll intentionally break a test, watch the pipeline fail, then fix it and watch it recover.

  • Open test.js in your text editor.
  • Find this line inside the first test:
          assert.strictEqual(body.status, 'healthy');
  • Change 'healthy' to 'broken' so the line looks like this:
          assert.strictEqual(body.status, 'broken');
  • Save test.js and push the breaking change by running:
git add .
git commit -m "Break test to verify pipeline catches failures"
git push origin main
  • Return to Pipelines in Azure DevOps. Watch the new run.
  • The Build stage should succeed (green checkmark), but the Test stage should fail (red X). This is Continuous Integration catching a bug before it reaches production.

What should I see?

The pipeline run summary shows the Build stage as green and the Test stage as red. If you click into the Test stage logs, you'll see the assertion error explaining that the test expected 'broken' but received 'healthy'.

  • Now fix the test. Open test.js and change 'broken' back to 'healthy':
          assert.strictEqual(body.status, 'healthy');
  • Save test.js and push the fix:
git add .
git commit -m "Fix test assertion"
git push origin main
  • Return to Pipelines and verify the new run completes with both stages green again.

You've now proven that your pipeline catches failures and recovers automatically. This is the core promise of CI: every push is validated, and broken code is caught immediately.

Test stage still failing after fixing?

  • Make sure you saved test.js before committing. Run git diff to confirm your change is staged.
  • Check that the assertion value is exactly 'healthy' (with single quotes, lowercase). Typos here cause silent mismatches.
  • My pipeline Test stage keeps failing even after I reverted my change. How do I debug this?

Secret mission

Deploy to Azure App Service

Your pipeline builds and tests your code automatically. But what about deploying it to a live website? Add a Deploy stage that takes your tested artifact and ships it to Azure App Service, creating a fully end-to-end CI/CD pipeline from code commit to live URL.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses free tiers for all services, so there are no ongoing costs.

Resources you used:

  • Azure DevOps organization and project (nodejs-cicd-demo).
  • Azure Repos repository with your application code.
  • CI/CD pipeline using Microsoft-hosted agents (free: 1,800 min/month).
  • Azure App Service web app (Free F1 tier, if you completed the Secret Mission).

Keep everything running

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

  • Your pipeline continues to trigger on pushes to main. The free tier (1,800 minutes/month) easily covers individual learning usage.
  • Azure Repos provides unlimited free private repos. No cost implications.
  • If you deployed to Azure App Service with the Free F1 SKU, that also incurs no charges.
  • Git remains installed on your machine. This is a standard development tool you'll use across many projects.

Pause - I'll come back to this later

Disable pipeline triggers so your pipeline stops running automatically, but keep all your code and configuration intact.

  • Option A: In your azure-pipelines.yml, change trigger: to trigger: none. Commit and push to disable CI triggers via YAML.
  • Option B: In Azure Pipelines, select your pipeline, click the three-dot menu (...), then select Settings. Toggle the pipeline to Disabled.
  • Your code, pipeline YAML, and artifacts all remain in Azure Repos for when you return.

Delete - I don't want to use this again

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

  • Delete the Azure DevOps project: go to Organization Settings, then Projects. Find nodejs-cicd-demo, click the three-dot menu, and select Delete. This removes the project, repository, pipeline, and all associated data.
  • If you completed the Secret Mission and created an Azure App Service, delete the resource group by running this command in your terminal:
az group delete --name [[RESOURCE_GROUP="your-resource-group-name"]] --yes

What does this command do?

The az group delete command removes the entire Azure resource group and all resources inside it, including your App Service. The --yes flag skips the confirmation prompt.

Nice Work!

Nice Work!

Nice work! You just built a professional CI/CD pipeline from scratch using Azure DevOps.

You've learned how to:

  • Built a multi-stage YAML pipeline with Build and Test stages that automatically runs on every code push to Azure Repos.
  • Configured Continuous Integration triggers so your pipeline fires automatically whenever you push code to the main branch.
  • Published build artifacts and added a pipeline status badge to your repository README.
  • Deployed your app to a live Azure App Service with a fully automated Deploy stage, creating an end-to-end CI/CD pipeline from code commit to live website.

Ready to quiz yourself?