Build a CI/CD Pipeline with Azure DevOps

Automate build, test, and deploy of a Node.js app with Azure DevOps.

Introduction

30 Second Summary

Every time a developer changes code, someone has to make sure it still works, package it up, and put it somewhere users can reach it. When teams do this by hand, mistakes creep in and releases slow to a crawl.

In this project, you will build a fully automated pipeline that builds, tests, and deploys a web application every time you push code to GitHub. No manual steps required.

What You'll Build

A complete CI/CD pipeline in Azure DevOps that watches your GitHub repository for code changes, automatically builds and tests your Node.js Express application, packages it as a deployable artifact, and deploys it to a live Azure App Service URL.

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

  • A Node.js Express API with an automated test suite that verifies your app works before every deployment.
  • A YAML pipeline in Azure DevOps that triggers on every push to GitHub, running build, test, and deploy stages automatically.
  • A live deployment on Azure App Service that updates itself whenever you push new code.
  • Secret Mission: Add a staging environment with approval gates so deployments go through staging before production.

Are there any prerequisites?

You should be comfortable with basic Git operations (commit and push) and familiar with running commands in a terminal. No prior Azure or DevOps experience is needed.

Set Up Your Accounts and Tools

Before you can build a pipeline, you need the platform that runs it and the tools that produce your code.

In this step, you will create a free Azure DevOps organization, set up a project to house your pipeline, and confirm that your local development tools are ready to go.

In this step, get ready to:
  • Create an Azure DevOps organization and project.
  • Request free pipeline parallelism (or confirm existing access).
  • Verify Node.js, Git, and VS Code are installed locally.
Create your Azure DevOps organization

Azure DevOps is Microsoft's platform for running CI/CD pipelines. You need an organization (your top-level account) and a project (the container for your pipeline and code connections).

  • Open your browser and navigate to dev.azure.com.
  • Click Start free.
  • Sign in with a Microsoft account or a GitHub account. If you do not have either, create a free Microsoft account when prompted.
  • Accept the Terms of Service and select Continue.
  • Enter a name for your organization (this appears in your URL). Choose a region near you, then click Continue.

Azure DevOps now prompts you to create your first project.

  • Enter nodejs-pipeline-demo as the project name.
  • Leave visibility set to Private.
  • Leave the version control as Git and the work item process as Agile.
  • Click Create.

You should see the project welcome page with options for Boards, Repos, and Pipelines.

No credit card required

Azure DevOps is free for up to 5 users with 1,800 free pipeline minutes per month. No credit card is needed to sign up.

Don't see the New project button?

You need to be the organization owner or a member of the Project Collection Administrators group. Since you just created the organization, you are automatically the owner. Try refreshing the page.

help me troubleshoot my Azure DevOps project creation

Request free parallelism (if new organization)

Azure DevOps pipelines run on hosted agents. New organizations do not receive free Microsoft-hosted parallel jobs by default. You need to request them before your pipeline can execute.

How long does this take?

The request typically takes 4-5 business days to process. You can continue with the project setup in the meantime. By the time you reach Step 4 (running the pipeline), your request should be approved.

If you already have an existing Azure DevOps organization with parallelism granted, you can skip this request entirely.

✔️ I have parallelism or submitted the request

You are all set. Continue to the next substep.

ⓧ I cannot wait 4-5 days

You can use a self-hosted agent running on your own machine instead. This lets you execute pipelines immediately without waiting for the free grant.

Self-hosted agent setup is more complex (downloading the agent, configuring a Personal Access Token, and referencing a custom pool in your YAML). If you prefer this path, Microsoft documents the full process in its self-hosted agent docs. For now, continue with the project. We will note where the pipeline YAML differs if you use a self-hosted agent.

Verify local tools

Your local machine needs Node.js (to run the app), Git (to push code), and a code editor. Let's confirm each is 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 your Node.js version by running:
node --version

This project requires Node.js v18 or later.

✔️ I see version 18 or higher

Your Node.js installation is ready. Continue to the Git check below.

ⓧ I see an older version

You have Node.js installed, but your version is too old for this project. The built-in test runner (node --test) requires v18 or later.

  • Visit https://nodejs.org and download the LTS installer for your operating system.
  • Run the installer and follow the prompts.
  • Close your terminal, open a new one, and run node --version again. You should now see v18 or higher.

macOS may still show the old version

macOS sometimes ships a system Node.js that shadows the newly installed version. If node --version still shows the old number after installing, open a fresh terminal tab and try again. If it persists, use the full path printed by the installer or install via a version manager like nvm.

ⓧ Command not found

Node.js is not installed on your machine. Let's install it now.

  • Visit https://nodejs.org and download the LTS installer for your operating system.
  • Run the installer and accept the default settings.
  • Close your terminal, open a new one, and run node --version to confirm. You should see v18 or higher.
  • Check your Git version by running:
git --version

✔️ I see a version number

Git is installed and ready. Continue to the editor check below.

ⓧ Command not found

Git is not installed. Download and install it from https://git-scm.com.

  • Run the installer with the default settings.
  • Close and reopen your terminal, then run git --version again. You should see a version number printed.
  • Finally, confirm you have a code editor. We recommend VS Code.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter.

✔️ VS Code opens

Your editor is ready. You are all set for the next step.

ⓧ Nothing opens or app not found

Download VS Code from https://code.visualstudio.com and run the installer. Any code editor works for this project, but VS Code has the best integration with Azure DevOps extensions.

Your accounts are live and your local tools are confirmed. Next up, you will build the Node.js application that your pipeline will test and deploy.

Create the Node.js Application

Your accounts and tools are ready. Now you need an application for your pipeline to actually build, test, and deploy.

In this step, you'll create a simple Node.js Express API with two endpoints that return JSON responses. This makes it easy to verify the app is working at every stage of the pipeline.

In this step, get ready to:
  • Scaffold a Node.js project with Express.
  • Write two API endpoints and automated tests.
  • Verify the app runs locally.
Scaffold the project and create the Express app

Every pipeline needs source code to work with. You'll create a project folder, initialize it as a Node.js project, and install the Express framework.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir nodejs-pipeline-demo
cd nodejs-pipeline-demo
  • Initialize a new Node.js project and install Express by running these commands:
npm init -y
npm install express@4.21.2

What do these commands do?

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

The npm install express@4.21.2 command downloads the Express framework and saves it as a dependency. Express makes it simple to create web APIs in Node.js.

  • Confirm the files exist by running:
ls

You should see node_modules, package.json, and package-lock.json in the output.

Command not found or permission error?

Make sure Node.js is installed correctly by running node --version. If you see a version number below 18, revisit Step 1 to upgrade.

help me fix an npm install error in my terminal

  • Open the nodejs-pipeline-demo folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the nodejs-pipeline-demo folder, and click Open.

Now you'll create the application code. Your Express app will have two routes: a root endpoint and a health check endpoint.

  • Create a new file called app.js by right-clicking in the Explorer sidebar and selecting New File.
  • Name the file app.js and paste the following code:
const express = require('express');
const app = express();

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

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

module.exports = app;

What does this code do?

This file defines your Express application with two GET routes. The root route (/) returns a JSON message confirming the pipeline is running.

The health route (/health) returns the server status with a timestamp. The module.exports = app line makes the app importable by other files, which is important for both the server and the tests.

  • Save app.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).

See a red underline on require?

Some VS Code extensions flag require as an issue if they expect ES module syntax. This is safe to ignore. Your project uses CommonJS modules, which is the default for Node.js.

help me understand why VS Code is showing a red underline on require in my app.js

  • Create another new file called server.js by right-clicking in the Explorer sidebar and selecting New File.
  • Name the file server.js and paste the following code:
const app = require('./app');

// Use the PORT environment variable (set by Azure App Service) or default to 3000
const port = process.env.PORT || 3000;

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

Why separate app.js and server.js?

Separating the app definition from the server startup lets you import app.js in your tests without actually starting a server on a fixed port. This is a common pattern for testable Node.js applications.

The process.env.PORT || 3000 line is important because Azure App Service injects a PORT environment variable at runtime. Locally, it falls back to port 3000.

  • Save server.js.

✔️ Awesome, I've got everything!

Great. Make sure both app.js and server.js are saved.

ⓧ I'd like to double check the full code

Here is the full app.js:

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;

Here is the full server.js:

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

const port = process.env.PORT || 3000;

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

A CI pipeline is only useful if it can catch broken code before it deploys. You'll add tests that verify your API routes return the correct responses.

  • Create a new folder called test by right-clicking in the Explorer sidebar and selecting New Folder.
  • Inside the test folder, create a new file called app.test.js.
  • Paste the following code into test/app.test.js:
const { describe, it } = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const app = require('../app');

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

  // Test the root route returns the expected JSON message
  it('should return a JSON message on GET /', (_, done) => {
    server = app.listen(0, () => {
      const port = server.address().port;
      baseUrl = `http://localhost:${port}`;

      http.get(`${baseUrl}/`, (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.message, 'Hello from Azure DevOps CI/CD Pipeline!');
          assert.strictEqual(body.status, 'running');
          server.close();
          done();
        });
      });
    });
  });

  // Test the health route returns healthy status with a timestamp
  it('should return healthy status on GET /health', (_, done) => {
    server = app.listen(0, () => {
      const port = server.address().port;
      baseUrl = `http://localhost:${port}`;

      http.get(`${baseUrl}/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, 'healthy');
          assert.ok(body.timestamp);
          server.close();
          done();
        });
      });
    });
  });
});

What does this test file do?

This uses the Node.js built-in test runner (node:test). No extra test libraries needed.

Each test starts a temporary server on a random port (app.listen(0)), makes an HTTP request to a route, and checks the response matches expected values. The server is closed after each test to avoid port conflicts.

  • Save test/app.test.js.

File not saving in the test folder?

Make sure you created the test folder first, then created app.test.js inside it. If the file ended up in the wrong location, drag it into the test folder in the Explorer sidebar.

help me move my test file into the correct folder in VS Code

Now you need to update package.json so Node.js knows how to start the app and run the tests.

  • Open package.json in the Explorer sidebar.
  • Replace the entire contents of the file with the following:
{
  "name": "nodejs-pipeline-demo",
  "version": "1.0.0",
  "description": "A Node.js app with Azure DevOps CI/CD pipeline",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "4.21.2"
  },
  "devDependencies": {}
}

What changed in package.json?

The "start" script tells Node.js to run server.js when you type npm start. Azure App Service uses this script to launch your app in production.

The "test" script uses node --test to run the built-in Node.js test runner. It automatically finds and executes any files matching the test/ pattern.

  • Save package.json.

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files: app.js, server.js, test/app.test.js, and package.json.

ⓧ I'd like to double check the full code

Here is test/app.test.js:

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

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

  it('should return a JSON message on GET /', (_, done) => {
    server = app.listen(0, () => {
      const port = server.address().port;
      baseUrl = `http://localhost:${port}`;

      http.get(`${baseUrl}/`, (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.message, 'Hello from Azure DevOps CI/CD Pipeline!');
          assert.strictEqual(body.status, 'running');
          server.close();
          done();
        });
      });
    });
  });

  it('should return healthy status on GET /health', (_, done) => {
    server = app.listen(0, () => {
      const port = server.address().port;
      baseUrl = `http://localhost:${port}`;

      http.get(`${baseUrl}/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, 'healthy');
          assert.ok(body.timestamp);
          server.close();
          done();
        });
      });
    });
  });
});

Here is package.json:

{
  "name": "nodejs-pipeline-demo",
  "version": "1.0.0",
  "description": "A Node.js app with Azure DevOps CI/CD pipeline",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "4.21.2"
  },
  "devDependencies": {}
}
Verify the app runs locally

Before pushing anything to a pipeline, you should confirm the app works on your own machine. You'll run the tests first, then start the server.

  • Open a terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Run the tests by running this command:
npm test

You should see output confirming both tests pass with checkmarks or "ok" indicators.

Tests failing?

If you see an error about Cannot find module '../app', make sure app.test.js is inside the test folder (not in the project root).

If you see a syntax error, double check that app.js has no missing commas or brackets. Compare against the full code in the double-check tab above.

help me debug my failing npm test

  • Start the server by running:
npm start

You should see Server running on port 3000 in the terminal.

  • Open your browser and go to http://localhost:3000.

You should see a JSON response with "message": "Hello from Azure DevOps CI/CD Pipeline!" displayed in the browser.

  • Go back to your terminal and press Ctrl+C to stop the server.

Don't see the JSON response?

Make sure the terminal still shows Server running on port 3000 (no crash after startup). If the server crashed immediately, check server.js for typos in the require('./app') line.

If port 3000 is already in use, stop whatever process is using it or change the port by running PORT=3001 npm start instead.

help me fix my Node.js server not responding at localhost:3000

Your application is ready. Next up, you'll push this code to GitHub and connect it to your Azure DevOps project so the pipeline can access it.

Push to GitHub and Connect Azure DevOps

Your Node.js app is running and tested locally. Now it needs a home on GitHub where Azure DevOps can watch for code changes.

In this step, you will push your code to a GitHub repository and connect it to Azure Pipelines. Once connected, Azure DevOps will detect every push you make and trigger your pipeline automatically.

In this step, get ready to:
  • Create a GitHub repository and push your code.
  • Connect your GitHub repository to Azure DevOps Pipelines.
  • Select the Starter pipeline template in the YAML editor.
Create a GitHub repository

Your pipeline needs a remote repository to pull code from. You will create a new repository on GitHub, then push your local project files to it.

  • Open your browser and go to github.com.
  • In the upper-right corner of any page, click the + icon, then click New repository.
  • Type nodejs-pipeline-demo as the repository name.
  • Set the visibility to Public.
  • Do NOT check any boxes under "Initialize this repository with" (no README, no .gitignore, no license). You already have local files to push.
  • Click Create repository.

Why leave the repository empty?

Since you already have local files, initializing with a README or .gitignore would create a conflict when you try to push. Starting with an empty repo lets you push your existing code cleanly.

GitHub shows a Quick Setup page with instructions. You will use the commands below instead.

  • Switch back to your terminal. Make sure you are inside your nodejs-pipeline-demo project folder.
  • Create a .gitignore file and initialize Git by running these commands:
echo "node_modules" > .gitignore
git init
git add .
git commit -m "Initial commit"
git branch -M main

What do these commands do?

  • echo "node_modules" > .gitignore creates a file that tells Git to ignore the node_modules folder. Dependencies will be reinstalled from package.json during the pipeline build.
  • git init turns your folder into a Git repository.
  • git add . stages all files (except those in .gitignore) for the first commit.
  • git commit -m "Initial commit" saves a snapshot of your project.
  • git branch -M main ensures your default branch is named main.
  • Connect your local repository to GitHub and push your code by running these commands:
git remote add origin [[GITHUB_REPO_URL="https://github.com/your-username/nodejs-pipeline-demo.git"]]
git push -u origin main

Where do I find my repository URL?

On the Quick Setup page that GitHub showed after creating the repository, copy the HTTPS URL. It looks like https://github.com/your-username/nodejs-pipeline-demo.git.

GitHub will ask you to authenticate

When you run git push, a browser window or credential prompt will appear asking you to sign in to GitHub. Follow the prompts to authorize access.

  • Refresh your GitHub repository page in the browser. You should see app.js, server.js, package.json, and a test folder listed.

Don't see your files on GitHub?

  • Make sure you ran git push -u origin main without errors. Check your terminal output for any authentication failures.
  • If you see a "repository not found" error, double-check that the URL matches the one on your GitHub Quick Setup page.
  • If the push was rejected because the remote contains work you do not have locally, you may have accidentally initialized the GitHub repo with a README. Delete the repo on GitHub, recreate it without initialization, and push again.

Why isn't my git push working?

Connect GitHub to Azure DevOps

Now that your code lives on GitHub, you will point Azure DevOps at it. This connection lets Azure Pipelines read your repository and trigger builds automatically on every push.

  • Open your Azure DevOps project at dev.azure.com and navigate to the nodejs-pipeline-demo project.
  • In the left navigation menu, click Pipelines.
  • Click Create Pipeline (or New pipeline if you already have pipelines in the project).
  • On the "Where is your code?" screen, select GitHub.

Azure DevOps may ask you to authorize

You may be redirected to GitHub to sign in and install the Azure Pipelines app. If prompted, click Approve & install to grant Azure DevOps access to your repositories.

  • On the "Select a repository" screen, find and select your nodejs-pipeline-demo repository.

Don't see your repository listed?

  • Make sure you authorized Azure DevOps to access your GitHub account in the previous step.
  • If you created the repository under a GitHub organization, you may need organization owner permissions to install the Azure Pipelines app.

Why can't Azure DevOps find my GitHub repository?

Choose the Starter pipeline template

Azure DevOps offers several pipeline templates based on the language it detects in your repository. You will pick the simplest template to start with and replace its content in the next step.

  • On the "Configure your pipeline" screen, select Starter pipeline.

The YAML editor opens with a basic azure-pipelines.yml file pre-filled with a trigger, pool, and steps section. Do not save or run this yet. You will replace the content in the next step.

What is azure-pipelines.yml?

This is a YAML file that lives inside your repository and defines every step of your pipeline. Because it is version-controlled alongside your code, changes to your pipeline are tracked just like any other code change.

Your code is on GitHub and Azure DevOps is connected. Next up, you will replace this starter template with a real CI pipeline that builds and tests your Node.js app automatically.

Write Your CI Pipeline

Your Azure DevOps project is now connected to GitHub and the YAML editor is open with a starter template.

In this step, you will replace that starter content with a real pipeline definition that installs Node.js, runs your tests, and packages your app into a deployable artifact. Every time you push to main, this pipeline will automatically build and verify your code.

In this step, get ready to:
  • Define a YAML pipeline that installs dependencies and runs tests.
  • Add artifact packaging so the build output is ready for deployment.
  • Save and run the pipeline to verify everything works.
Define the pipeline YAML

The starter template Azure DevOps generated is a placeholder. You need to replace it with a pipeline definition that matches your Node.js project.

  • In the YAML editor, select all the existing starter content and delete it.
  • Paste the following YAML to define your Build stage with Node.js installation, dependency install, and test steps:
trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

variables:
  nodeVersion: '18.x'

stages:
  - stage: Build
    displayName: 'Build and Test'
    jobs:
      - job: BuildJob
        displayName: 'Build, Test, and Package'
        steps:
          - task: UseNode@1
            inputs:
              version: $(nodeVersion)
            displayName: 'Install Node.js'

          - script: |
              npm install
            displayName: 'Install dependencies'

          - script: |
              npm test
            displayName: 'Run tests'

What does this YAML do?

  • trigger: - main tells Azure DevOps to run this pipeline automatically whenever code is pushed to the main branch.
  • vmImage: 'ubuntu-latest' runs the pipeline on a Microsoft-hosted Linux virtual machine.
  • UseNode@1 installs the specified Node.js version (18.x) on the build agent.
  • The two script steps run npm install and npm test just like you did locally.

Why YAML over the classic editor?

YAML pipelines are version-controlled alongside your application code. If someone changes the pipeline, you can see exactly what changed in the Git history.

Microsoft recommends YAML pipelines for all new projects. The classic drag-and-drop editor is considered legacy.

Add the artifact publish steps

Running tests proves your code works, but the pipeline also needs to package the application so it can be deployed later.

  • In the YAML editor, place your cursor at the end of the file (after the 'Run tests' step).
  • Add the following two tasks to archive and publish the build artifact:
          - task: ArchiveFiles@2
            inputs:
              rootFolderOrFile: '$(Build.SourcesDirectory)'
              includeRootFolder: false
              archiveType: 'zip'
              archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip'
              replaceExistingArchive: true
            displayName: 'Archive application'

          - task: PublishPipelineArtifact@1
            inputs:
              artifactName: 'nodejs-app'
              targetPath: '$(Build.ArtifactStagingDirectory)'
            displayName: 'Publish pipeline artifact'

What do these tasks do?

  • ArchiveFiles@2 zips your entire application source into a single file. The zip is stored in a staging directory that Azure DevOps manages.
  • PublishPipelineArtifact@1 publishes that zip as a named artifact called nodejs-app. Later stages (like deployment) can download this artifact without rebuilding.

YAML indentation errors?

YAML is whitespace-sensitive. The two new tasks must be indented at exactly the same level as the previous - task: UseNode@1 and - script: lines (10 spaces from the left margin).

If you see a red squiggly line in the editor, check that you have not mixed tabs and spaces. Help me fix my YAML indentation in the Azure DevOps editor.

\u2714\ufe0f Awesome, I've got everything!

Great. Make sure the full YAML is ready before you save and run in the next substep.

\u24e7 I'd like to double check the full code

Here is the complete azure-pipelines.yml file. Your editor content should match this exactly:

trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

variables:
  nodeVersion: '18.x'

stages:
  - stage: Build
    displayName: 'Build and Test'
    jobs:
      - job: BuildJob
        displayName: 'Build, Test, and Package'
        steps:
          - task: UseNode@1
            inputs:
              version: $(nodeVersion)
            displayName: 'Install Node.js'

          - script: |
              npm install
            displayName: 'Install dependencies'

          - script: |
              npm test
            displayName: 'Run tests'

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

          - task: PublishPipelineArtifact@1
            inputs:
              artifactName: 'nodejs-app'
              targetPath: '$(Build.ArtifactStagingDirectory)'
            displayName: 'Publish pipeline artifact'
Save and run the pipeline

Saving commits the azure-pipelines.yml file directly to your GitHub repository and immediately triggers the first pipeline run.

  • Click **Save and run** in the top-right corner of the YAML editor.
  • A dialog appears asking for a commit message. Leave the default message or enter something like Add CI pipeline.
  • Make sure **Commit directly to the main branch** is selected.
  • Click **Save and run** again to confirm.

What happens next?

Azure DevOps commits azure-pipelines.yml to the main branch of your GitHub repo. Because the file includes trigger: - main, this commit itself triggers the pipeline to run.

You are taken to the pipeline run view where you can watch each step execute in real time.

  • Wait for the run to complete. Click on the **BuildJob** job to expand it and watch the individual steps (Install Node.js, Install dependencies, Run tests, Archive application, Publish pipeline artifact).
  • Confirm that all steps show a green checkmark.

You should see each step complete with a green checkmark. The final step, Publish pipeline artifact, confirms that an artifact named nodejs-app was published successfully.

Pipeline run failed?

  • If you see a "No hosted parallelism has been purchased or granted" error, your organization needs free parallelism approved. Submit the request at https://aka.ms/azpipelines-parallelism-request (takes 4-5 business days) or use a self-hosted agent.
  • If a test step fails, check that your test/app.test.js file was committed correctly to GitHub. The pipeline runs the same npm test command you verified locally.
  • If you see a YAML parsing error, click the failed step to view the error message. A common cause is incorrect indentation in the pipeline file.

My Azure DevOps pipeline run is failing. Help me figure out why based on the error message I see in the run logs.

Your CI pipeline is now building and testing your app automatically on every push. Next up, you will deploy this artifact to a live Azure App Service URL so the world can see your app.

Add Continuous Deployment to Azure App Service

Your pipeline builds, tests, and packages your app automatically. The next step is getting that package onto a live server without lifting a finger.

In this step, you will create an Azure App Service to host your Node.js app, connect Azure DevOps to your Azure subscription, and add a Deploy stage to your pipeline YAML.

In this step, get ready to:
  • Create an Azure App Service on the Free F1 tier.
  • Create a service connection between Azure DevOps and your Azure subscription.
  • Add a Deploy stage to your pipeline and trigger a full CI/CD run.
Create an Azure App Service

Your pipeline produces a packaged zip artifact. Now you need a destination for that artifact to land. Azure App Service is a managed hosting platform that runs your Node.js server and gives it a public URL.

  • Open the Azure Portal by navigating to portal.azure.com in your browser and sign in with your Azure account.
  • Click the Cloud Shell icon in the top navigation bar (it looks like a terminal prompt).
  • If prompted, select Bash as your shell type.
  • Clone your GitHub repository into Cloud Shell by running this command:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/nodejs-pipeline-demo.git
  • Move into the project directory by running:
cd nodejs-pipeline-demo
  • Create your App Service by running this command:
az webapp up --name [[APPNAME="your-unique-app-name"]] --sku F1 --runtime "NODE:18-lts"

What does this command do?

The az webapp up command does several things in one step. It creates a default resource group, creates an App Service plan, creates a web app with your specified name, and deploys the current directory's code with build automation enabled.

The --sku F1 flag selects the Free pricing tier (no cost). The --runtime "NODE:18-lts" flag tells Azure to run your app on Node.js 18 LTS.

Choose a unique app name

Your app name must be unique across all of Azure. Valid characters are a-z, 0-9, and -. A good pattern is to combine your name with the project, like nodejs-pipeline-demo-yourname.

  • Wait for the command to finish. It takes a minute or two. When complete, you will see a JSON output that includes a URL field.
  • Note down the URL (it follows the format https://<your-app-name>.azurewebsites.net). You will need this later.
  • Open that URL in a new browser tab to confirm your app is live. You should see the JSON response from your Express app.

Seeing an error about runtime not found?

Make sure you are running the command from inside the nodejs-pipeline-demo directory in Cloud Shell. The az webapp up command needs to detect your app's code to auto-detect the runtime.

If you see a name conflict error, your chosen app name is already taken. Try a different unique name.

Help me troubleshoot the az webapp up command

Add the deployment stage to your pipeline

Your App Service is live, but right now it only has the code you manually pushed via Cloud Shell. To make deployments automatic, you need to connect Azure DevOps to your Azure subscription and add a Deploy stage to your pipeline YAML.

A service connection gives Azure DevOps permission to deploy resources into your Azure subscription without asking for credentials on every pipeline run.

  • Return to your Azure DevOps project in your browser.
  • Click Project settings (the gear icon) in the bottom-left corner.
  • Under Pipelines, click Service connections.
  • Click New service connection.
  • Select Azure Resource Manager, then click Next.
  • Select App registration (automatic) with the credential Workload identity federation.
  • For Scope level, select Subscription.
  • Select your Azure subscription from the dropdown.
  • Leave Resource group empty (this gives access to the whole subscription).
  • Enter a name for your service connection (e.g. azure-app-service-connection). Remember this name exactly. You will use it in your pipeline YAML.
  • Check the Grant access permission to all pipelines checkbox.
  • Click Save.

What is a service connection?

A service connection stores the authentication details Azure DevOps needs to talk to your Azure subscription. When you selected the automatic option, Azure DevOps created a service principal (an identity) in your Azure Active Directory and gave it Contributor access to your subscription.

Your pipeline references this connection by name in the azureSubscription field of the AzureWebApp@1 task.

Now add the Deploy stage to your pipeline YAML. This stage downloads the artifact from the Build stage and pushes it to your App Service.

  • Switch back to your code editor.
  • Open azure-pipelines.yml.
  • Add the following Deploy stage at the bottom of the file, after the Build stage's closing lines:
  - stage: Deploy
    displayName: 'Deploy to Azure App Service'
    dependsOn: Build
    condition: succeeded()
    jobs:
      - job: DeployJob
        displayName: 'Deploy to App Service'
        steps:
          - task: DownloadPipelineArtifact@2
            inputs:
              artifactName: 'nodejs-app'
              targetPath: '$(Pipeline.Workspace)/drop'
            displayName: 'Download artifact'

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

What does this stage do?

  • dependsOn: Build ensures this stage only runs after the Build stage passes.
  • condition: succeeded() means the Deploy stage is skipped if tests fail in the Build stage.
  • DownloadPipelineArtifact@2 pulls the zip archive that the Build stage published.
  • AzureWebApp@1 deploys the zip to your App Service using your service connection for authentication.
  • Replace the azureSubscription value with the exact name you gave your service connection (e.g. azure-app-service-connection).
  • Replace the appName value with the app name you used when creating the App Service.
  • Save azure-pipelines.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved azure-pipelines.yml with both the Build and Deploy stages.

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

variables:
  nodeVersion: '18.x'

stages:
  - stage: Build
    displayName: 'Build and Test'
    jobs:
      - job: BuildJob
        displayName: 'Build, Test, and Package'
        steps:
          - task: UseNode@1
            inputs:
              version: $(nodeVersion)
            displayName: 'Install Node.js'

          - script: |
              npm install
            displayName: 'Install dependencies'

          - script: |
              npm test
            displayName: 'Run tests'

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

          - task: PublishPipelineArtifact@1
            inputs:
              artifactName: 'nodejs-app'
              targetPath: '$(Build.ArtifactStagingDirectory)'
            displayName: 'Publish pipeline artifact'

  - stage: Deploy
    displayName: 'Deploy to Azure App Service'
    dependsOn: Build
    condition: succeeded()
    jobs:
      - job: DeployJob
        displayName: 'Deploy to App Service'
        steps:
          - task: DownloadPipelineArtifact@2
            inputs:
              artifactName: 'nodejs-app'
              targetPath: '$(Pipeline.Workspace)/drop'
            displayName: 'Download artifact'

          - task: AzureWebApp@1
            inputs:
              azureSubscription: '<your-service-connection-name>'
              appType: 'webAppLinux'
              appName: '<your-app-name>'
              package: '$(Pipeline.Workspace)/drop/$(Build.BuildId).zip'
            displayName: 'Deploy to Azure Web App'
Trigger a full CI/CD run

The pipeline triggers automatically whenever you push to the main branch. To test the full CI/CD flow, push the updated YAML and a small code change.

  • Open app.js in your code editor.
  • Find the line that says:
  res.json({ message: 'Hello from Azure DevOps CI/CD Pipeline!', status: 'running' });
  • Update the message string to something new, for example:
  res.json({ message: 'Deployed automatically via Azure DevOps CI/CD!', status: 'running' });
  • Save app.js.
  • Also update the test in test/app.test.js to match your new message string, so the test continues to pass.
  • Commit and push all changes by running these commands:
git add .
git commit -m "Add Deploy stage and update API message"
git push
  • Return to your Azure DevOps project in the browser.
  • Click Pipelines in the left sidebar.
  • You should see a new pipeline run triggered by your push. Click into it to watch both stages execute in real time.

What should I see?

The pipeline run shows two stages: Build and Test followed by Deploy to Azure App Service. Each stage turns green when it completes successfully. The Deploy stage may take a minute or two as it uploads your artifact to Azure.

First-run authorization prompt

On the first deploy, Azure DevOps may ask you to authorize the pipeline to use the service connection. If you see a Permit button on the pipeline run page, click it to allow the deployment to proceed.

  • Once both stages show green, open your App Service URL in a browser tab (https://<your-app-name>.azurewebsites.net).
  • You should see the updated JSON message from your code change. This confirms your full CI/CD pipeline is working end to end.

Deploy stage failed?

Check the error message in the pipeline logs. The most common issues are:

  • The azureSubscription value in your YAML does not match the service connection name exactly. Names are case-sensitive.
  • The appName value in your YAML does not match the App Service name you created.
  • You did not click Permit when prompted. Return to the pipeline run and look for a permissions banner.

Help me debug my failed Deploy stage

Your pipeline now handles the entire journey from code push to live deployment. Every time you push to main, Azure DevOps builds, tests, packages, and deploys your app automatically. Next up, you will take this further with a staging environment and approval gates in the Secret Mission.

Secret mission

Add a Staging Environment with Approval Gates

In production, teams never deploy code directly to users without testing it first. Add a staging environment that catches issues before they reach your live URL, plus a manual approval gate so you control exactly when code goes 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. Both your Azure App Service (Free F1 tier) and Azure DevOps project are free, so there are no ongoing costs.

Resources you used:

  • Azure App Service (Production) on the Free F1 tier.
  • Azure App Service (Staging) on the Free F1 tier.
  • Azure DevOps organization and project (nodejs-pipeline-demo).
  • GitHub repository (nodejs-pipeline-demo).
  • Local project files in the nodejs-pipeline-demo folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the pipeline further.

  • Your App Service on the F1 tier costs nothing. It stays live at your .azurewebsites.net URLs.
  • Your Azure DevOps pipeline only runs when you push to GitHub, consuming your free 1,800 minutes per month.
  • Your GitHub repository stays accessible as normal.

Pause - I'll come back to this later

Stop pushing to the repository. The pipeline only runs on triggers, so no minutes are consumed while paused. Your App Service stays live at no cost.

  • Stop pushing code to the nodejs-pipeline-demo GitHub repository. The pipeline will not run until you push again.
  • Both App Services remain live at their URLs. The Free F1 tier does not charge you.
  • Your Azure DevOps project, environments, and service connection remain configured and ready for when you return.

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 your Azure App Services
  • Open the Azure Portal at portal.azure.com.
  • Search for Resource groups in the top search bar.
  • Select the resource group that contains your App Services. This was created automatically when you ran az webapp up.
  • Click Delete resource group at the top of the page.
  • Type the resource group name to confirm, then click Delete. This removes both your production and staging App Services.
Delete your Azure DevOps project
  • In Azure DevOps, open your nodejs-pipeline-demo project.
  • Click Project Settings in the bottom-left corner.
  • On the Overview page, scroll to the bottom and click Delete.
  • Type the project name to confirm deletion.
Delete your GitHub repository
  • Go to your nodejs-pipeline-demo repository on GitHub.
  • Click Settings in the top tab bar.
  • Scroll to the bottom of the page to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.
Delete your local project files
  • Delete the local project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/nodejs-pipeline-demo

Windows

rmdir /s /q %USERPROFILE%\Desktop\nodejs-pipeline-demo

Nice Work!

Nice Work!

You just built a professional-grade CI/CD pipeline from scratch. Every time you push code to GitHub, your pipeline automatically builds, tests, packages, and deploys your application to a live URL.

You've learned how to:

  • Build a Node.js Express API with automated tests using the built-in Node.js test runner.
  • Create a YAML-based CI pipeline in Azure DevOps that automatically builds, tests, and packages your app on every GitHub push.
  • Set up Continuous Deployment that deploys your app to a live Azure App Service URL automatically.
  • Added staging environments with approval gates so deployments pass through staging and require manual approval before reaching production.

Ready to quiz yourself?