Set Up a CI/CD Pipeline with Azure DevOps

Build a multi-stage YAML pipeline that deploys a Node.js app to Azure.

Introduction

30 Second Summary

Every time you make a change to your code, someone has to build it, test it, and deploy it to a live server. When done manually, this process is slow, error-prone, and tedious.

In this project, you will build a CI/CD pipeline using Azure DevOps that automatically builds, tests, and deploys a Node.js web application every time you push code to your repository.

What You'll Build

A multi-stage YAML pipeline in Azure DevOps that takes a Node.js Express application from code push to live deployment on Azure App Service.

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

  • An Azure DevOps organization and project configured with a YAML pipeline connected to GitHub.
  • A Node.js application with unit tests, automatically built and verified on every push.
  • A working multi-stage pipeline with Build, Test, and Deploy stages that deploys your app live to Azure App Service.
  • Secret Mission: Add a staging environment with a manual approval gate before deploying to production.

Are there any prerequisites?

You need basic familiarity with Git (commit, push). No prior DevOps or Azure experience is required.

Before We Start

Every time a developer changes code, someone has to build it, test it, and push it to the live server. Doing this by hand is slow and error-prone.

In this project, you will set up an automated pipeline that handles all of this for you. First, you need a few accounts and tools in place.

In this step, get ready to:
  • Set up a GitHub account and install Git.
  • Create an Azure DevOps organization.
  • Create a free Azure account for deployment.
Create a GitHub account and install Git

Your pipeline needs a place to store code. GitHub is the code hosting platform you will use, and Git is the version control tool that lets you push code to GitHub from your computer.

  • Go to https://github.com and sign up for a free account (skip this if you already have one).
  • Once your account is ready, open a terminal to check whether Git is already installed.
  • 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

You should see something like git version 2.x.x. Git is ready to go.

ⓧ Command not found

Git is not installed yet. Follow the instructions for your operating system below.

macOS

  • Install Git via Xcode Command Line Tools by running this command:
xcode-select --install
  • A dialog will appear asking you to install the command line developer tools. Click Install and wait for it to finish.
  • Once complete, verify by running git --version again.

Windows

  • Download the Git installer from https://git-scm.com/downloads/win.
  • Run the downloaded installer and accept the default settings for each screen.
  • Close and reopen Command Prompt, then run git --version again to confirm the install worked.

Still not seeing a version number?

Make sure you closed and reopened your terminal after installing. Some systems need a fresh terminal session to find newly installed programs.

help me troubleshoot why git --version is not working after installation

Create an Azure DevOps organization

Azure DevOps is the platform that will run your automated pipeline. You need an organization, which acts as the top-level container for your projects and pipelines.

  • Go to https://dev.azure.com in your browser.
  • Click Start free.
  • Sign in with your Microsoft account. If you do not have one, follow the prompts to create a new Microsoft account.
  • Accept the Terms of Service and select Continue.
  • Enter an organization name (something short and memorable like your name or company).
  • Select a hosting region close to you.
  • Complete the captcha and select Continue.

Your organization is now live. You can access it anytime at https://dev.azure.com/your-org-name.

What is an Azure DevOps organization?

An organization is the top-level grouping in Azure DevOps. Inside it, you create projects that hold your code repositories, pipelines, and work items. One organization can hold many projects.

Create an Azure free account

Later in this project, you will deploy your app to Azure App Service. To use Azure services, you need an Azure account. The free tier includes $200 credit for 30 days.

  • Go to https://azure.microsoft.com/free in your browser.
  • Click Start free and sign in with the same Microsoft account you used for Azure DevOps.
  • Follow the sign-up prompts to create your Azure account. You will need to provide a phone number and payment method for identity verification.

Will I be charged?

In this project you will use the Azure App Service Free F1 tier, which costs $0 per month. You will not be charged for the resources created in this project, even after the 30-day trial period ends.

Having trouble signing up?

If you see errors during the Azure sign-up process, make sure you are using a personal Microsoft account and not a work account with restricted permissions.

help me troubleshoot Azure free account sign-up issues

Your accounts are ready. Next up, you will create the Node.js application and push it to a GitHub repository.

Set Up Your Accounts and Tools

A CI/CD pipeline connects multiple services together. Your code lives in GitHub, your pipeline runs in Azure DevOps, and your app deploys to Azure App Service. Each service needs its own account.

In this step, you will create accounts for all three services and install Git on your machine. Once these are ready, you can focus entirely on building your pipeline.

In this step, get ready to:
  • Create a GitHub account and install Git.
  • Create an Azure DevOps organization.
  • Create an Azure account for App Service deployment.
Create a GitHub account and install Git

GitHub hosts your application code. Git is the command-line tool that pushes your code to GitHub.

  • If you do not already have a GitHub account, go to https://github.com and click Sign up.
  • Follow the prompts to create your account with a username, email, and password.

Next, install Git on your machine. The steps differ depending on your operating system.

macOS

On macOS, Git is often pre-installed with the Xcode Command Line Tools. If it is not installed yet, running the verify command in the next section will prompt you to install it automatically.

If you prefer to install Git explicitly, run this command in your terminal:

xcode-select --install

Follow the dialog that appears to complete the installation.

Windows

  • Download the Git installer from https://git-scm.com/downloads/win.
  • Run the downloaded installer.
  • Accept all the default options during the installation wizard and click Next through each screen.
  • Click Install, then Finish when the installation completes.

Now verify that Git is installed correctly.

  • Open a terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal (macOS) or Git Bash (Windows) and press Enter.
  • Check your Git installation by running this command:
git --version

✔️ I see a version number

If you see output like git version 2.x.x, Git is installed and ready. Move on to the next substep.

ⓧ Command not found

If you see an error mentioning the command was not found, Git is not installed yet.

  • On macOS, a dialog should appear offering to install the Xcode Command Line Tools. Click Install and wait for it to finish.
  • On Windows, download and run the installer from https://git-scm.com/downloads/win.
  • After installing, close and reopen your terminal, then run git --version again.
Create an Azure DevOps organization

Azure DevOps is where your pipeline lives. An organization is the top-level container for all your projects and pipelines.

  • Go to https://dev.azure.com.
  • Click Start free.
  • Sign in with your Microsoft account. If you do not have one, create a new Microsoft account during the sign-in flow.
  • Accept the Terms of Service and select Continue.
  • Enter an organization name (this can be your name or a short project label).
  • Choose a region near you from the dropdown.
  • Complete the captcha and select Continue.

Your organization is now live. Its URL is https://dev.azure.com/{Your_Organization}.

What is an Azure DevOps organization?

An organization is the top-level grouping in Azure DevOps. It holds one or more projects, each of which can contain pipelines, repos, boards, and more.

The free tier gives you 1 Microsoft-hosted parallel job with 1,800 minutes per month. That is more than enough for this project.

Having trouble signing in?

Make sure you are using a personal Microsoft account, not a work or school account that might have restrictions. If the page asks you to join an existing organization instead of creating one, sign out and try a different Microsoft account.

help me troubleshoot signing in to Azure DevOps

Create an Azure account for App Service deployment

Your pipeline will deploy the app to Azure App Service. To use App Service, you need an Azure account with an active subscription.

  • Go to https://azure.microsoft.com/free.
  • Click Start free and sign in with the same Microsoft account you used for Azure DevOps.
  • Fill in the required information including your name, phone number, and a credit card for identity verification.
  • Accept the terms and click Sign up.

Will I be charged?

The Azure free account includes $200 credit for 30 days. In this project, you will use the Free F1 App Service tier which costs $0 even after the trial period ends.

The credit card is only used for identity verification. You will not be charged unless you manually upgrade to a paid plan.

  • Once your account is ready, confirm you can access the Azure Portal at https://portal.azure.com.
  • You should see the Azure Portal home page with a search bar at the top and a list of Azure services.

Seeing an error about free trials and Azure DevOps?

Azure DevOps does not support the Azure Free Trial subscription type for billing. This is fine for this project because you will use the free tier of Azure DevOps (no billing needed) and the Free F1 App Service tier (no charges).

help me understand Azure DevOps billing and the free trial limitation

All three accounts are ready and Git is installed. Next up, you will create a Node.js application and push it to GitHub so your pipeline has something to build and deploy.

Create the Node.js App and GitHub Repository

Your accounts and tools are set up. Now it's time to build the actual application that your pipeline will automatically build, test, and deploy.

A CI/CD pipeline needs code to work with. In this step, you'll create a simple Node.js web app with tests, store it in a GitHub repository, and have a solid foundation for the pipeline stages ahead.

In this step, get ready to:
  • Create a GitHub repository and clone it to your machine.
  • Build a Node.js Express application with endpoints and tests.
  • Push all your code to GitHub.
Create a new GitHub repository and clone it

You need a remote repository on GitHub to store your application code. The pipeline you build later will connect to this repository and trigger builds automatically whenever you push changes.

  • Go to https://github.com and sign in to your account.
  • Click the + icon in the top-right corner and select New repository.
  • Enter azure-devops-pipeline-demo as the repository name.
  • Leave the repository set to Public.
  • Do NOT check Add a README file. You will push code from your local machine, so the repository must start empty.
  • Click Create repository.

Why leave the repository empty?

If GitHub initializes the repository with a README, your local push will conflict with the remote history. Starting empty means your first push becomes the initial commit with no merge issues.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type Terminal (macOS) or Git Bash (Windows), and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your new repository and move into it by running these commands:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/azure-devops-pipeline-demo.git
cd azure-devops-pipeline-demo

What does this command do?

The git clone command downloads a copy of the remote repository to your local machine. The cd command moves your terminal into the newly created project folder.

  • Confirm you are inside the correct folder by running:
ls

The folder should be empty (or show only a hidden .git folder). That's expected for an empty repository.

Seeing 'repository not found'?

Double-check that you typed your exact GitHub username in the clone URL. The repository name must be azure-devops-pipeline-demo with no typos.

If Git asks for credentials, enter your GitHub username and a personal access token (not your password). GitHub no longer accepts passwords for HTTPS cloning.

Why can't I clone my repository?

Initialize the Node.js application

Now you'll set up a basic Express web server with a health endpoint and automated tests. This gives your pipeline something meaningful to build and verify.

  • Initialize a new Node.js project and install your dependencies by running these commands:
npm init -y
npm install express
npm install --save-dev jest supertest

What do these commands do?

The npm init -y command creates a package.json file with default settings. This file tracks your project's metadata and dependencies.

The npm install commands download Express (a web framework for building HTTP servers) and two testing tools: Jest (a test runner) and supertest (for sending HTTP requests in tests).

  • Confirm the install worked by running:
ls node_modules

You should see a long list of package folders. This confirms your dependencies installed correctly.

Seeing 'npm: command not found'?

This means Node.js is not installed on your machine. Install it from https://nodejs.org (choose the LTS version), then close and reopen your terminal before retrying.

Help me install Node.js

  • Create the application files by running:
touch app.js server.js app.test.js
  • Verify the files were created by running:
ls

You should see app.js, server.js, app.test.js, node_modules, package.json, and package-lock.json in the output.

  • Open app.js in your preferred text editor and paste the following code:
const express = require('express');
const app = express();

// Root endpoint returns a welcome message
app.get('/', (req, res) => {
  res.json({ message: 'Hello from Azure DevOps CI/CD Pipeline!', status: 'running' });
});

// Health check endpoint returns server status
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

module.exports = app;

What does this code do?

  • Creates an Express app with two endpoints: / (root) and /health.
  • The root endpoint returns a JSON message confirming the pipeline app is running.
  • The health endpoint returns the server status and a timestamp. Deployment platforms often use health endpoints to verify an app is alive.
  • The module.exports = app line makes the app importable by other files (the server and the tests).
  • Save app.js.

You won't see a change yet. The next file (server.js) will make this app runnable.

✔️ 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();

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

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

module.exports = app;
  • Open server.js in your text editor and paste the following code:
const app = require('./app');
const port = process.env.PORT || 3000;

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

What does this code do?

  • Imports the Express app from app.js.
  • Reads the PORT environment variable (set by Azure App Service in production) or defaults to port 3000 for local development.
  • Starts the server and logs which port it is listening on.
  • Save server.js.
  • Test that your app starts correctly by running:
node server.js

You should see Server running on port 3000 in your terminal. This confirms your Express app is working.

  • Press Ctrl+C to stop the server.

Not seeing the server message?

Make sure you saved both app.js and server.js before running the command. Check that the require('./app') path in server.js exactly matches the filename.

If you see Cannot find module 'express', run npm install express again and make sure you are inside the azure-devops-pipeline-demo folder.

Help me fix my Node.js server error

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

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

app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});
  • Open app.test.js in your text editor and paste the following code:
const request = require('supertest');
const app = require('./app');

// Test the root endpoint
describe('GET /', () => {
  it('should return a JSON message', async () => {
    const response = await request(app).get('/');
    expect(response.status).toBe(200);
    expect(response.body.message).toBe('Hello from Azure DevOps CI/CD Pipeline!');
    expect(response.body.status).toBe('running');
  });
});

// Test the health endpoint
describe('GET /health', () => {
  it('should return healthy status', async () => {
    const response = await request(app).get('/health');
    expect(response.status).toBe(200);
    expect(response.body.status).toBe('healthy');
    expect(response.body).toHaveProperty('timestamp');
  });
});

What does this code do?

  • Uses supertest to send HTTP requests to your app without actually starting a server.
  • The first test verifies that the root endpoint (/) returns a 200 status and the expected JSON message.
  • The second test verifies that the health endpoint (/health) returns a healthy status and includes a timestamp.
  • Your CI/CD pipeline will run these tests automatically on every push. If either test fails, the pipeline blocks deployment.
  • Save app.test.js.

You won't run these tests yet. First you need to update package.json with the correct test script.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

const request = require('supertest');
const app = require('./app');

describe('GET /', () => {
  it('should return a JSON message', async () => {
    const response = await request(app).get('/');
    expect(response.status).toBe(200);
    expect(response.body.message).toBe('Hello from Azure DevOps CI/CD Pipeline!');
    expect(response.body.status).toBe('running');
  });
});

describe('GET /health', () => {
  it('should return healthy status', async () => {
    const response = await request(app).get('/health');
    expect(response.status).toBe(200);
    expect(response.body.status).toBe('healthy');
    expect(response.body).toHaveProperty('timestamp');
  });
});
  • Open package.json in your text editor. Replace its entire contents with the following:
{
  "name": "azure-devops-pipeline-demo",
  "version": "1.0.0",
  "description": "A demo Node.js app for Azure DevOps CI/CD pipeline",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "test": "jest --forceExit"
  },
  "dependencies": {
    "express": "^4.21.2"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "supertest": "^7.1.0"
  }
}

What changed in package.json?

  • The main field now points to server.js so Node.js knows the entry point.
  • The start script launches the server. Azure App Service will run this command automatically.
  • The test script runs Jest with the --forceExit flag, which ensures the test process terminates cleanly after all tests finish.

Your version numbers may differ slightly depending on when you ran npm install. That is fine. The app will work the same way.

  • Save package.json.
  • Run your tests to verify everything works:
npm test

You should see both tests passing with green text. Jest reports 2 passed and 2 total.

Tests failing?

If you see Cannot find module 'supertest', run npm install --save-dev supertest again. Make sure you are in the azure-devops-pipeline-demo folder when running the command.

If the test script says jest: command not found, confirm that jest appears under devDependencies in your package.json and re-run npm install.

Help me fix my failing tests

✔️ Awesome, I've got everything!

Great. Double-check you've saved package.json before moving on.

ⓧ I'd like to double check the full code

{
  "name": "azure-devops-pipeline-demo",
  "version": "1.0.0",
  "description": "A demo Node.js app for Azure DevOps CI/CD pipeline",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "test": "jest --forceExit"
  },
  "dependencies": {
    "express": "^4.21.2"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "supertest": "^7.1.0"
  }
}
Push the application code to GitHub

Your app is working locally. Now push everything to GitHub so your CI/CD pipeline can access the code in the next step.

  • Stage, commit, and push all your files by running these commands:
git add .
git commit -m "Initial Node.js application"
git push origin main

What do these commands do?

  • The git add . command stages all files in the current folder for commit.
  • The git commit command saves a snapshot of your staged changes with a descriptive message.
  • The git push origin main command uploads your local commits to the main branch on GitHub.
  • Go back to your repository page on GitHub and refresh the page.

You should see app.js, server.js, app.test.js, package.json, and package-lock.json listed in the repository. Your code is now on GitHub and ready for a pipeline to pick up.

Push was rejected?

If you see error: failed to push, this usually means the remote has content your local copy does not. This happens if you initialized the repo with a README. Delete the repository on GitHub, recreate it without initializing any files, and clone again.

If Git asks for authentication, use your GitHub username and a personal access token as the password.

Help me push my code to GitHub

Your application is built and stored on GitHub. Next up, you'll connect this repository to Azure DevOps and create your first pipeline.

Create an Azure DevOps Project and Connect to GitHub

Your Node.js app is on GitHub and ready to go. Now you need to tell Azure DevOps where to find that code so it can build and deploy it automatically.

In this step, you will create a project inside your Azure DevOps organization and connect it to your GitHub repository. This connection is what allows Azure Pipelines to detect new pushes and kick off your pipeline.

In this step, get ready to:
  • Create a new Azure DevOps project called pipeline-demo.
  • Connect the project to your GitHub repository.
  • Select the Starter pipeline template.
Create a new Azure DevOps project

A project in Azure DevOps is a container that groups your pipelines, repositories, and work items together. You will create one dedicated project for this pipeline.

  • Return to your Azure DevOps organization at https://dev.azure.com/{YourOrganization}.
  • Click New project in the top-right corner.
  • Enter pipeline-demo in the Project name field.
  • Set Visibility to Private.
  • Leave the other defaults (Git for version control, Agile for work item process).
  • Click Create.

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

What is a project in Azure DevOps?

A project isolates its data from other projects in the same organization. Each project has its own Repos, Pipelines, Boards, and Artifacts sections.

Think of the organization as the company, and each project as a separate product or team workspace within it.

Don't see the New project button?

You must be a member of the Project Collection Administrators group. If you created the organization yourself, you are automatically a member. If someone else created it, ask them to grant you the "Create new projects" permission.

help me fix missing permissions for creating a new Azure DevOps project

Create a new pipeline and connect to GitHub

Now you will create a pipeline inside this project and point it at your GitHub repository. Azure DevOps uses the Azure Pipelines GitHub app to access your code.

  • In the left sidebar, click Pipelines.
  • Click Create Pipeline (or New pipeline if you already have pipelines listed).
  • On the "Where is your code?" screen, select GitHub.
  • If prompted, sign in to GitHub and authorize the Azure Pipelines app.
  • Select your azure-devops-pipeline-demo repository from the list.

What happens when you authorize the Azure Pipelines app?

Azure DevOps installs a GitHub app on your repository. This app listens for push events and notifies Azure Pipelines to start a build. You can manage or revoke this access later from your GitHub account's Installed GitHub Apps settings.

Can't see your repository in the list?

Make sure you authorized the Azure Pipelines app for the correct GitHub account. If you have multiple GitHub accounts, verify you are signed in with the account that owns the azure-devops-pipeline-demo repository.

help me connect my GitHub repository to Azure Pipelines

Choose the Starter pipeline template

Azure DevOps offers several pipeline templates to help you get started. You will select the simplest one so you can replace it with your own custom YAML in the next step.

  • On the "Configure your pipeline" screen, select Starter pipeline.
  • Azure DevOps opens an editor with a basic azure-pipelines.yml file.

Do NOT click Save and run yet

You will replace the starter YAML content with your own multi-stage pipeline in the next step. Leave the editor open without saving.

You should see the pipeline editor with a short YAML file containing a trigger section and a few script steps. This confirms that Azure DevOps is connected to your GitHub repository and ready for pipeline configuration.

Why YAML pipelines instead of the Classic editor?

Microsoft recommends YAML pipelines for new users. The azure-pipelines.yml file lives in your repository alongside your application code. This means pipeline changes go through the same code review, branching, and version history as the rest of your project.

The Classic editor uses a drag-and-drop UI that stores pipeline definitions on the server. It works, but it cannot be versioned in Git.

Your Azure DevOps project is connected to GitHub and the pipeline editor is ready. Next up, you will replace this starter template with a real multi-stage pipeline that builds, tests, and deploys your app.

Write Your Multi-Stage YAML Pipeline

Your Azure DevOps project is connected to GitHub and the starter pipeline template is open in the editor. Now it's time to replace that starter content with your own multi-stage pipeline.

A YAML pipeline defines your automation as code. You'll write two stages: a Build stage that packages your application, and a Test stage that runs your Jest tests. Each stage runs on its own cloud-hosted agent in sequence.

In this step, get ready to:
  • Define a Build stage that installs dependencies and publishes an artifact.
  • Add a Test stage that runs your Jest tests and reports results.
  • Save and run the pipeline to verify both stages pass.
Replace the starter YAML with a Build stage

The starter template Azure DevOps generated is a placeholder. You need to replace it with a real pipeline configuration that defines a trigger, a pool (the virtual machine that runs your code), and a Build stage.

  • In the Azure DevOps pipeline editor, select all the starter YAML content (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
  • Paste the following Build stage configuration:
trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

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

          - 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 files'

          - task: PublishBuildArtifacts@1
            inputs:
              PathtoPublish: '$(Build.ArtifactStagingDirectory)'
              ArtifactName: 'drop'
              publishLocation: 'Container'
            displayName: 'Publish artifact'

What does this code do?

  • trigger: - main tells Azure Pipelines to run this pipeline automatically every time code is pushed to the main branch.
  • pool: vmImage: 'ubuntu-latest' tells Azure DevOps to run the pipeline on a Microsoft-hosted Linux virtual machine.
  • The Build stage installs Node.js 20.x, runs npm install, then archives all project files into a zip.
  • PublishBuildArtifacts@1 uploads the zip as an artifact named drop so later stages (like Deploy) can access the packaged code.

What is an artifact?

An artifact is a file or package produced by one stage that other stages can download and use. By publishing an artifact in the Build stage, you avoid rebuilding the code in every subsequent stage.

You won't see any output yet. The next substep adds the Test stage, then you'll save and run the full pipeline.

Add a Test stage

The Test stage runs after Build completes successfully. It re-installs dependencies (each stage starts on a fresh machine), runs your Jest tests, and publishes the results so Azure DevOps can display them in the pipeline UI.

  • Below the last line of the Build stage (the displayName: 'Publish artifact' line), add a blank line and paste the Test stage:
  - stage: Test
    displayName: 'Test Stage'
    dependsOn: Build
    jobs:
      - job: TestJob
        displayName: 'Run Tests'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '20.x'
            displayName: 'Install Node.js 20.x'

          - script: npm install
            displayName: 'Install dependencies'

          - script: npm test -- --ci --reporters=jest-junit
            displayName: 'Run tests'
            env:
              JEST_JUNIT_OUTPUT_DIR: '$(System.DefaultWorkingDirectory)/test-results'

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: 'JUnit'
              testResultsFiles: '**/test-results/junit.xml'
              mergeTestResults: true
              testRunTitle: 'Jest Unit Tests'
            displayName: 'Publish test results'
            condition: always()

What does this code do?

  • dependsOn: Build ensures the Test stage only starts after the Build stage succeeds.
  • npm test -- --ci --reporters=jest-junit runs your tests in CI mode and outputs results in JUnit XML format that Azure DevOps can read.
  • PublishTestResults@2 uploads the test results so they appear directly in the pipeline run summary.
  • condition: always() ensures test results are published even if some tests fail. This way you always see which tests broke.

✔️ Awesome, I've got everything!

Great. Double check the indentation looks correct and move on to save and run your pipeline.

ⓧ I'd like to double check the full code

Here is the complete azure-pipelines.yml file with both stages:

trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

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

          - 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 files'

          - task: PublishBuildArtifacts@1
            inputs:
              PathtoPublish: '$(Build.ArtifactStagingDirectory)'
              ArtifactName: 'drop'
              publishLocation: 'Container'
            displayName: 'Publish artifact'

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

          - script: npm install
            displayName: 'Install dependencies'

          - script: npm test -- --ci --reporters=jest-junit
            displayName: 'Run tests'
            env:
              JEST_JUNIT_OUTPUT_DIR: '$(System.DefaultWorkingDirectory)/test-results'

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: 'JUnit'
              testResultsFiles: '**/test-results/junit.xml'
              mergeTestResults: true
              testRunTitle: 'Jest Unit Tests'
            displayName: 'Publish test results'
            condition: always()
Save and run the pipeline

Your pipeline YAML is complete with both stages. Saving in the Azure DevOps editor commits the file directly to your repository and triggers the first pipeline run.

  • Click Save and run in the top-right corner of the editor.
  • A dialog appears asking for a commit message. Leave the default message and click Save and run again.
  • Azure DevOps commits azure-pipelines.yml to your repository's main branch and starts the pipeline.

What should I see?

The pipeline run page shows your two stages side by side: Build Stage and Test Stage. Each stage displays a progress spinner while running, then a green checkmark when it passes. The whole run typically takes 1-3 minutes.

  • Wait for both stages to finish. You should see green checkmarks on both the Build Stage and Test Stage.
  • Click on the Test Stage to expand it. You should see the test results summary showing your two test suites passing.

Pipeline showing a red X?

  • Click on the failed stage to see the error logs. YAML indentation errors are the most common cause. Every level in YAML uses exactly 2 spaces (not tabs).
  • If the Test stage fails with a reporter error, add jest-junit to your package.json devDependencies by running npm install --save-dev jest-junit locally, then commit and push the updated package.json and package-lock.json. The pipeline will re-trigger automatically.
  • If you see a permissions error about parallel jobs, your free tier may need activation. Check your organization settings under Pipelines > Parallel jobs to confirm you have 1 Microsoft-hosted job available.

Help me troubleshoot my pipeline failure

Your pipeline now automatically builds and tests your code every time you push to main. Next up, you'll add a Deploy stage that ships your tested code to a live Azure App Service.

Add the Deployment Stage to Azure App Service

Your pipeline builds your code and runs tests automatically. However, you still need to deploy the application manually to get it live.

In this step, you will create an Azure App Service instance to host your application, connect Azure DevOps to your Azure account, and add a Deploy stage to your pipeline. After this, every push to main will build, test, and deploy your app automatically.

In this step, get ready to:
  • Create an Azure App Service instance to host your Node.js application.
  • Create a service connection so Azure DevOps can deploy to your Azure account.
  • Add a Deploy stage to your pipeline YAML that deploys the app to Azure.
Create an Azure App Service instance

Your pipeline needs a destination to deploy your application. Azure App Service is a managed hosting platform that runs your web app without requiring you to manage servers.

  • Return to the Azure Portal at portal.azure.com.
  • In the search bar at the top, type App Services and select it from the results.
  • Click Create, then select Web App.
  • Under Subscription, select your Azure subscription.
  • Under Resource Group, click Create new and enter pipeline-demo-rg.
  • In the Name field, enter a globally unique name for your web app (for example, nextwork-pipeline-yourname). Write this name down. You will need it later.
  • Set Publish to Code.
  • Set Runtime stack to Node 20 LTS.
  • Set Operating System to Linux.
  • Under Pricing plan, select the Free F1 tier.
  • Click Review + create, then click Create.

What is a Resource Group?

A Resource Group is a logical container in Azure that holds related resources together. By putting your App Service in pipeline-demo-rg, you can manage and delete all project resources in one place later.

Wait for the deployment to complete. Azure shows a notification when your App Service is ready.

Find your app URL

Once created, your App Service URL will be https://your-app-name.azurewebsites.net. You can find this on the App Service Overview page under Default domain.

Create a service connection in Azure DevOps

Azure DevOps needs permission to deploy to your Azure account. A service connection stores authentication credentials so your pipeline can push code to App Service.

  • Switch back to your Azure DevOps project at dev.azure.com.
  • In the bottom-left corner, click Project settings.
  • In the left sidebar under Pipelines, select Service connections.
  • Click New service connection.
  • Select Azure Resource Manager and click Next.
  • Select Service principal (automatic) and click Next.
  • Choose your Azure subscription from the dropdown.
  • Under Resource group, select pipeline-demo-rg.
  • In the Service connection name field, enter azure-app-service-connection.
  • Click Save.

What is a service connection?

A service connection securely stores the credentials that Azure DevOps uses to talk to external services. The "Service principal (automatic)" option creates a service principal in your Azure account and grants it permission to deploy to the selected resource group.

Azure sign-in popup not appearing?

If you are not prompted to authenticate with Azure, make sure you are signed in to the Azure Portal in the same browser. Try using an InPrivate or Incognito window if the popup is blocked.

Help me troubleshoot my service connection

Add the Deploy stage to your pipeline YAML

With the App Service and service connection ready, you can now add the final piece of your pipeline. The Deploy stage downloads the build artifact and pushes it to your live App Service.

  • In the left sidebar of Azure DevOps, click Pipelines.
  • Select your pipeline from the list.
  • Click Edit in the top-right corner to open the YAML editor.
  • Scroll to the bottom of your azure-pipelines.yml file, after the Test stage's closing lines.
  • Add the Deploy stage by pasting the following code at the bottom of the file:
  - stage: Deploy
    displayName: 'Deploy to Azure'
    dependsOn: Test
    condition: succeeded()
    jobs:
      - deployment: DeployJob
        displayName: 'Deploy to App Service'
        environment: 'production'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: DownloadBuildArtifacts@1
                  inputs:
                    buildType: 'current'
                    downloadType: 'single'
                    artifactName: 'drop'
                    downloadPath: '$(System.ArtifactsDirectory)'
                  displayName: 'Download artifact'

                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'azure-app-service-connection'
                    appType: 'webAppLinux'
                    appName: '[[APPNAME="your globally unique App Service name"]]'
                    package: '$(System.ArtifactsDirectory)/drop/$(Build.BuildId).zip'
                  displayName: 'Deploy to Azure App Service'

What does this code do?

  • dependsOn: Test ensures the Deploy stage only runs after tests pass.
  • condition: succeeded() prevents deployment if any previous stage failed.
  • The deployment job type (instead of a regular job) tells Azure DevOps this is a deployment targeting an environment.
  • DownloadBuildArtifacts@1 retrieves the zipped application from the Build stage.
  • AzureWebApp@1 deploys the zip package to your App Service using the service connection you just created.
  • Make sure the appName value matches the exact name you gave your App Service in the Azure Portal.
  • Click Save and run.
  • In the dialog, select Commit directly to the main branch and click Save and run.

Watch the pipeline run through all three stages: Build, Test, and Deploy. Each stage shows a green checkmark when it passes.

  • Once the Deploy stage completes, open a new browser tab and navigate to your App Service URL (https://your-app-name.azurewebsites.net).
  • You should see a JSON response: {"message":"Hello from Azure DevOps CI/CD Pipeline!","status":"running"}.

Pipeline failing at the Deploy stage?

Check that your appName in the YAML exactly matches the name you entered when creating the App Service. Also confirm that your service connection name is azure-app-service-connection (case-sensitive).

If you see a permissions error, return to Project settings > Service connections and verify the connection shows a green checkmark.

Help me debug my Deploy stage failure

✔️ Awesome, I've got everything!

Great. Double check you see the JSON response in your browser and all three pipeline stages show green checkmarks.

ⓧ I'd like to double check the full code

Here is the complete azure-pipelines.yml including all three stages:

trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

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

          - 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 files'

          - task: PublishBuildArtifacts@1
            inputs:
              PathtoPublish: '$(Build.ArtifactStagingDirectory)'
              ArtifactName: 'drop'
              publishLocation: 'Container'
            displayName: 'Publish artifact'

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

          - script: npm install
            displayName: 'Install dependencies'

          - script: npm test -- --ci --reporters=jest-junit
            displayName: 'Run tests'
            env:
              JEST_JUNIT_OUTPUT_DIR: '$(System.DefaultWorkingDirectory)/test-results'

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: 'JUnit'
              testResultsFiles: '**/test-results/junit.xml'
              mergeTestResults: true
              testRunTitle: 'Jest Unit Tests'
            displayName: 'Publish test results'
            condition: always()

  - stage: Deploy
    displayName: 'Deploy to Azure'
    dependsOn: Test
    condition: succeeded()
    jobs:
      - deployment: DeployJob
        displayName: 'Deploy to App Service'
        environment: 'production'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: DownloadBuildArtifacts@1
                  inputs:
                    buildType: 'current'
                    downloadType: 'single'
                    artifactName: 'drop'
                    downloadPath: '$(System.ArtifactsDirectory)'
                  displayName: 'Download artifact'

                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'azure-app-service-connection'
                    appType: 'webAppLinux'
                    appName: 'your-app-name-here'
                    package: '$(System.ArtifactsDirectory)/drop/$(Build.BuildId).zip'
                  displayName: 'Deploy to Azure App Service'

Your pipeline now handles the entire journey from code push to live deployment. Next up, you will tackle a Secret Mission that adds a staging environment with manual approval gates.

Secret mission

Add a Staging Environment with Manual Approval

Ready for a real-world deployment pattern? Add a staging environment between your Test and Production stages, complete with a manual approval gate. Your pipeline will pause and wait for your sign-off before deploying to production.

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. The Azure App Service Free F1 tier costs $0/month, and Azure DevOps is free for up to 5 users with 1,800 pipeline minutes per month. There are no ongoing costs for this project.

Resources you used:

  • Azure Resource Group pipeline-demo-rg (contains both production and staging App Services).
  • Azure DevOps project pipeline-demo (pipelines, environments, service connection).
  • GitHub repository azure-devops-pipeline-demo.
  • Local project folder azure-devops-pipeline-demo on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your pipeline.

  • The Free F1 App Service tier costs nothing. Both your production and staging apps stay live.
  • Your pipeline continues to build and deploy on every push to main.
  • You get 1,800 free pipeline minutes per month with the Microsoft-hosted agent.
  • Git remains installed on your machine for future projects.

Pause - I'll come back to this later

Stop pushing code to save pipeline minutes. Your App Service instances stay live but use no pipeline minutes while idle.

  • Stop making pushes to the main branch. The pipeline only triggers on new commits.
  • Your App Service instances remain deployed at their URLs at no cost (Free F1 tier).
  • Your Azure DevOps project, environments, and service connection remain intact.
  • When you are ready to resume, push a new commit to main and the pipeline runs again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Delete the Azure Resource Group (this removes both App Services):
  • Go to the Azure Portal at portal.azure.com.
  • Search for Resource groups in the top search bar.
  • Select pipeline-demo-rg.
  • Click Delete resource group at the top of the Overview page.
  • Type pipeline-demo-rg to confirm, then click Delete.
  • Delete the Azure DevOps project:
  • Sign in at dev.azure.com.
  • Click Organization settings in the bottom-left corner.
  • Select Projects in the left sidebar.
  • Check the box next to pipeline-demo and click Delete.
  • Confirm by typing the project name, then click Delete.
  • Delete the GitHub repository (optional):
  • Go to your azure-devops-pipeline-demo repository on GitHub.
  • Click Settings (top tab bar).
  • Scroll to the bottom to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.
  • Delete the local project folder:

macOS

  • Remove the local folder by running:
cd ~/Desktop
rm -rf azure-devops-pipeline-demo

Windows

  • Remove the local folder by running:
cd ~\Desktop
rmdir /s /q azure-devops-pipeline-demo

Nice Work!

Nice Work!

You just built a professional CI/CD pipeline from scratch using Azure DevOps. Every time you push code, your pipeline automatically builds, tests, and deploys your application to a live environment.

Here's what you accomplished:

  • Built a multi-stage YAML pipeline with Build, Test, and Deploy stages that automatically triggers on every push to main.
  • Connected GitHub to an Azure DevOps project using a service connection to authenticate deployments to Azure App Service.
  • Deployed a live Node.js web application to Azure App Service via the pipeline, with automated test reporting visible directly in the pipeline run UI.
  • Added a staging environment with manual approval gates for production-safe deployments.

Ready to quiz yourself?