CI/CD Pipeline for a Node.js App
Build an Azure DevOps pipeline that auto-builds and tests a Node.js app.
Introduction
30 Second Summary
Every time a developer pushes new code, someone has to check that it still works, package it up, and get it ready for users. Doing this by hand is slow and error-prone.
In this project, you will build an automated CI/CD pipeline using Azure DevOps that builds, tests, and packages a Node.js application every time you push code to GitHub.
What You'll Build
A complete CI/CD pipeline that watches your GitHub repository and automatically builds, tests, and packages your Node.js application on every code push, using a self-hosted agent installed on your Mac.
By the end of this project, you'll have:
- A Node.js Express app with automated unit tests pushed to GitHub.
- A self-hosted pipeline agent running on your Mac that executes build jobs.
- A YAML pipeline that automatically builds, tests, and publishes a packaged artifact on every code push.
- Secret Mission: Add a deployment stage that deploys your app to Azure App Service.
Are there any prerequisites?
You need a GitHub account to get started. All other tools (Azure DevOps, the pipeline agent, and Git) will be set up during the project.
Set Up Azure DevOps and Create a Project
Before you can automate anything, you need a place to define and run your pipelines. Azure DevOps is Microsoft's platform for managing code, builds, and releases in one place.
In this step, you'll create your Azure DevOps account, set up a project, and generate an access token. The access token lets your local machine communicate securely with Azure DevOps later.
In this step, get ready to:
- Sign up for Azure DevOps and create an organization and project.
- Create a Personal Access Token (PAT) for agent registration.
- Verify your project is accessible in the browser.
Sign up for Azure DevOps and create a project
Azure DevOps organizes everything into organizations (your team's top-level container) and projects (individual workspaces for repos, pipelines, and boards).
- Open your browser and navigate to dev.azure.com.
- Click Start free.
- Select Sign in with GitHub and authorize Azure DevOps to access your GitHub account.
Why sign in with GitHub?
Signing in with GitHub simplifies the connection later when Azure DevOps needs to read your repository. It also means one less password to manage.
- When prompted to create an organization, enter a name for your organization (for example, your name or a short identifier like my-org).
- Click Continue.
- You'll land on the Create a project to get started screen.
- Enter azure-pipelines-demo as your project name.
- Leave visibility set to Private.
- Click Create project.
Your project dashboard loads, showing sections for Boards, Repos, Pipelines, and more.
Stuck on a verification screen?
Some accounts are asked to verify their identity with a phone number or email code before the organization is created. Complete the verification and try again.
Help me get past the Azure DevOps sign-up verification
Create a Personal Access Token
A Personal Access Token (PAT) is a password-like string that lets tools authenticate with Azure DevOps on your behalf. You'll use this token in a later step to register your self-hosted build agent.
- Click your profile icon in the top-right corner of the Azure DevOps page.
- Select Personal access tokens.
- Click + New Token.
A panel opens where you configure the token's name, expiration, and permissions.
- Enter agent-setup in the Name field.
- Set the Expiration to 30 days (the default).
- Under Scopes, select Custom defined.
- Scroll down to find Agent Pools and check Read & manage.
- Click Create.
Copy your token now
Azure DevOps only shows your PAT value once. After you close this dialog, you cannot retrieve it again.
Copy the token and paste it somewhere safe (a notes app or password manager). You'll need it in Step 3 when configuring your build agent.
- Click the copy icon next to your token value.
- Paste the token into a secure location (a notes app or password manager).
- Click Close.
Why does the PAT need the Agent Pools scope?
The Agent Pools (Read & manage) permission lets your local machine register itself as a build agent in Azure DevOps. Without this scope, the agent setup script will fail with an authorization error.
Verify your project is accessible
- Click the Azure DevOps logo in the top-left corner to return to your organization's home page.
- Confirm that your azure-pipelines-demo project is listed on the page.
- Click the project name to open its dashboard.
Your browser's address bar should show a URL like https://dev.azure.com/{your-org}/azure-pipelines-demo.
Don't see your project?
Make sure you're signed into the correct organization. Check the top-left dropdown to see if you have multiple organizations and switch to the one you just created.
Help me find my Azure DevOps project
Your Azure DevOps home base is ready and your PAT is saved for later. Next up, you'll build the Node.js app that your pipeline will automate.
Create the Node.js App and Push to GitHub
Your Azure DevOps organization and project are ready to go. Now you need something for your pipeline to actually build.
In this step, you will create a simple Node.js web app with automated tests, then push it to GitHub. This gives your pipeline a real codebase to work with.
In this step, get ready to:
- Create a Node.js project with Express and automated tests.
- Run the tests locally to confirm they pass.
- Push the project to a GitHub repository.
Create the project folder and install dependencies
Every Node.js project starts with a folder and a package.json file that tracks its dependencies and scripts.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir azure-pipelines-demo
cd azure-pipelines-demo
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The -y flag tells npm to accept all default values and generate a package.json immediately. This file is the manifest for your project. It stores your app's name, scripts, and dependencies.
- Install Express as a dependency by running:
npm install express
- Confirm the installation succeeded by running:
ls node_modules | head -5
You should see a list of package folder names. This confirms Express and its dependencies were installed.
Seeing 'npm: command not found'?
This means Node.js is not installed or not on your system PATH. Download and install the latest LTS version from nodejs.org, then close and reopen your terminal.
Help me fix my Node.js installation.
Now you need to update package.json with the correct scripts and description for your project. The test script tells Node.js to use its built-in test runner. The start script tells it how to launch your app.
- Open the azure-pipelines-demo folder in your code editor.
- Open package.json and replace its entire contents with the following:
{
"name": "azure-pipelines-demo",
"version": "1.0.0",
"description": "A simple Node.js app for Azure DevOps pipeline demo",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node --test"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.21.0"
}
}
What does this code do?
The "start" script launches your app with node index.js. The "test" script runs node --test, which uses Node.js's built-in test runner to discover and execute test files.
The dependencies section lists Express as the web framework your app uses. The ^4.21.0 notation means any compatible version 4.21.0 or higher.
- Save package.json.
Create the Express server and test file
Your app needs two files: a server that handles web requests, and a test file that verifies the server works correctly.
- Create a new file called index.js in your azure-pipelines-demo folder and paste the following code:
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
// Home route - returns a greeting
app.get('/', (req, res) => {
res.json({ message: 'Hello from Azure Pipelines!' });
});
// Health check route - confirms the app is running
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
// Only start the server if this file is run directly
if (require.main === module) {
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
}
// Export the app so tests can access it
module.exports = app;
What does this code do?
This creates an Express web server with two routes. The / route returns a JSON greeting. The /health route returns status information, which is useful for monitoring.
The if (require.main === module) block means the server only starts listening when you run the file directly. When tests import this file, they get access to the app without starting a live server.
- Save index.js.
- Create a new file called index.test.js in the same folder and paste the following code:
const { test } = require('node:test');
const assert = require('node:assert');
const app = require('./index');
// Verify the app exports a function (Express apps are functions)
test('app module exports an express app', () => {
assert.strictEqual(typeof app, 'function');
});
// Verify the home route exists
test('app has GET / route', () => {
const routes = app._router.stack.filter(r => r.route);
const homeRoute = routes.find(r => r.route.path === '/');
assert.ok(homeRoute, 'GET / route should exist');
});
// Verify the health route exists
test('app has GET /health route', () => {
const routes = app._router.stack.filter(r => r.route);
const healthRoute = routes.find(r => r.route.path === '/health');
assert.ok(healthRoute, 'GET /health route should exist');
});
What does this code do?
These tests use Node.js's built-in test runner (node:test). Each test() block verifies one thing about your app.
The first test confirms the app exports a function (which is how Express apps work). The second and third tests check that your two routes actually exist by inspecting the router's internal stack.
- Save index.test.js.
- Run the tests to confirm everything works by running:
npm test
You should see output showing 3 tests passed with no failures.
Tests failing or showing errors?
If you see Cannot find module './index', make sure index.js is saved in the same folder as index.test.js.
If you see Cannot find module 'express', run npm install again to reinstall dependencies.
Help me debug my failing Node.js tests.
Initialize Git and push to GitHub
Your pipeline will pull code from GitHub, so you need to push your project to a repository. First, you will create a .gitignore file to keep the large node_modules folder out of version control.
- Create a new file called .gitignore in your azure-pipelines-demo folder and paste the following:
node_modules/
- Save .gitignore.
- Back in your terminal, initialize a Git repository and create your first commit by running:
git init
git add .
git commit -m "Initial commit: Node.js app with tests"
What do these commands do?
The git init command turns your folder into a Git repository. The git add . command stages all your files (except those in .gitignore). The git commit command saves a snapshot of your code.
Now you need a GitHub repository to push this code to.
- Open your browser and go to github.com.
- Click the + icon in the top right corner, then select New repository.
- Enter azure-pipelines-demo as the repository name.
- Leave it set to Public.
- Do NOT check any of the initialization boxes (no README, no .gitignore, no license).
- Click Create repository.
Why skip the initialization options?
You already have local files and a Git history. If GitHub adds its own files (like a README), your local and remote histories will conflict and you will need extra steps to merge them.
GitHub shows a quick setup page with commands for pushing an existing repository. You will use those now.
- Connect your local repo to GitHub and push your code by running these commands (replace the URL with your own repository URL from the GitHub page):
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/azure-pipelines-demo.git"]]
git branch -M main
git push -u origin main
What do these commands do?
The git remote add origin command links your local repository to the GitHub repository. The git branch -M main command renames your default branch to main. The git push -u origin main command uploads your code to GitHub.
- Refresh your GitHub repository page in the browser.
You should see package.json, index.js, index.test.js, and .gitignore listed in your repository.
Push failed or asking for credentials?
If Git asks for a username and password, GitHub no longer accepts passwords for HTTPS pushes. Use a GitHub Personal Access Token instead, or switch to SSH authentication.
If you see error: remote origin already exists, run git remote remove origin first, then try the git remote add command again.
Help me push my code to GitHub.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here is the complete code for each file:
{
"name": "azure-pipelines-demo",
"version": "1.0.0",
"description": "A simple Node.js app for Azure DevOps pipeline demo",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node --test"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.21.0"
}
}
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({ message: 'Hello from Azure Pipelines!' });
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
if (require.main === module) {
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
}
module.exports = app;
const { test } = require('node:test');
const assert = require('node:assert');
const app = require('./index');
test('app module exports an express app', () => {
assert.strictEqual(typeof app, 'function');
});
test('app has GET / route', () => {
const routes = app._router.stack.filter(r => r.route);
const homeRoute = routes.find(r => r.route.path === '/');
assert.ok(homeRoute, 'GET / route should exist');
});
test('app has GET /health route', () => {
const routes = app._router.stack.filter(r => r.route);
const healthRoute = routes.find(r => r.route.path === '/health');
assert.ok(healthRoute, 'GET /health route should exist');
});
node_modules/
Your Node.js app is built, tested, and live on GitHub. Next up, you will install a self-hosted agent on your Mac so Azure DevOps has somewhere to run your pipeline.
Install and Configure the Self-Hosted Agent
Your code is on GitHub and your Azure DevOps project is ready. The next piece of the puzzle is the pipeline agent. This is the machine that actually executes your pipeline jobs.
You'll install a self-hosted agent directly on your Mac. This means your pipeline runs locally, giving you full control over the build environment and instant feedback on every job.
In this step, get ready to:
- Download the Azure Pipelines agent for macOS.
- Configure the agent to connect to your Azure DevOps organization.
- Start the agent and verify it appears Online.
Why a self-hosted agent instead of Microsoft-hosted?
New Azure DevOps organizations do not automatically receive the free Microsoft-hosted parallel job grant. You have to submit a request that can take several business days to process.
A self-hosted agent avoids this blocker entirely. It runs on your own machine with unlimited free minutes. The YAML pipeline syntax is identical either way, so everything you learn here transfers directly to Microsoft-hosted agents later.
Download the agent from Azure DevOps
Azure DevOps provides a pre-packaged agent binary for each operating system. You'll download the macOS version from your organization's agent pool settings.
- In Azure DevOps, click the gear icon in the bottom-left corner to open Organization settings.
- In the left sidebar under Pipelines, click Agent pools.
- Click the Default pool.
- Click the Agents tab.
- Click New agent.
- Select the macOS tab in the dialog that appears.
- Click the Download button to download the agent tar.gz file to your Mac.
What is an agent pool?
An agent pool is a group of machines that can run pipeline jobs. The Default pool is created automatically for every organization. When your pipeline runs, Azure DevOps picks an available agent from the pool to execute the work.
Extract the agent and configure it
The downloaded file is a compressed archive. You need to extract it to a dedicated folder, clear macOS security attributes, and then run the configuration script to connect it to your Azure DevOps organization.
- Create the agent directory and extract the archive into it by running these commands:
mkdir ~/myagent
cd ~/myagent
tar xzf ~/Downloads/vsts-agent-osx-arm64-*.tar.gz
What do these commands do?
- mkdir ~/myagent creates a new folder in your home directory to hold the agent files.
- cd ~/myagent moves your terminal into that folder.
- tar xzf extracts the compressed archive. The wildcard * matches whatever version number is in the filename.
- Confirm the agent files were extracted by running:
ls
You should see files like config.sh, run.sh, and a bin folder listed in the output.
macOS has a security feature called Gatekeeper that flags files downloaded from the internet. You need to clear these flags before the agent can run properly.
- Clear the Gatekeeper extended attributes by running this command:
xattr -c ~/myagent
What does xattr -c do?
The xattr -c command clears extended attributes that macOS attaches to downloaded files. Without this, Gatekeeper shows security warnings for every file inside the agent directory.
Now you'll run the configuration script to connect the agent to your Azure DevOps organization. This script asks several questions interactively.
- Start the configuration by running:
./config.sh
The script prompts you for several values. Enter the following when asked:
- For server URL, enter:
https://dev.azure.com/[[AZURE_ORG="your-organization-name"]]
- For authentication type, press Enter to accept the default (PAT).
- For personal access token, paste the PAT you created in Step 1.
- For agent pool, press Enter to accept the default (Default).
- For agent name, press Enter to accept the default (your Mac's hostname).
- For work folder, press Enter to accept the default.
You should see a message that says Settings Saved at the end of the configuration.
Getting an authentication error?
- Make sure you're pasting the full PAT token from Step 1. It should be a long string of characters.
- Verify the server URL matches your organization name exactly. Check your browser's address bar at dev.azure.com to confirm.
- Confirm that your PAT has the Agent Pools (Read & manage) scope selected. If not, create a new PAT with the correct scope.
Still stuck? Help me troubleshoot my agent configuration error
Start the agent and verify it is online
The agent is configured but not yet running. You need to start it so it can listen for pipeline jobs from Azure DevOps.
- Start the agent by running:
./run.sh
You should see output ending with Listening for Jobs. This means your agent is running and ready to pick up pipeline jobs.
Keep this terminal window open
The agent runs in the foreground of this terminal session. If you close the window or press Ctrl+C, the agent stops. Keep it running and open a new terminal tab or window for any other commands.
Now verify the agent appears in Azure DevOps.
- Go back to your browser and navigate to Organization settings.
- Click Agent pools in the left sidebar under Pipelines.
- Click the Default pool.
- Click the Agents tab.
You should see your agent listed with a green Online status. This confirms your Mac is connected to Azure DevOps and ready to run pipeline jobs.
Agent showing as Offline?
- Check the terminal where you ran ./run.sh. If it shows an error or has stopped, try running it again.
- Refresh the Agent pools page in Azure DevOps. It can take a few seconds for the status to update.
- If you see no agent listed at all, re-run ./config.sh and double-check your organization URL and PAT.
Need more help? Help me get my self-hosted agent online
Your build agent is online and waiting for work. Next up, you'll write the YAML pipeline that tells this agent exactly what to do when code is pushed.
Write the YAML Pipeline
Your Node.js app is ready, your GitHub repo is live, and your self-hosted agent is online and waiting for work.
The missing piece is a set of instructions that tells Azure DevOps exactly what to do when new code arrives. Those instructions live in a YAML file called azure-pipelines.yml. In this step, you'll write that pipeline definition and push it to GitHub so Azure DevOps can find it.
In this step, get ready to:
- Create an azure-pipelines.yml file with trigger and pool configuration.
- Add pipeline steps for installing dependencies, running tests, and publishing an artifact.
- Commit and push the pipeline file to GitHub.
Create the pipeline file with trigger and pool
Every Azure DevOps pipeline needs to know two things: when to run and where to run. The trigger defines the "when" and the pool defines the "where."
- In your code editor, create a new file called azure-pipelines.yml in the root of your azure-pipelines-demo folder (the same folder where index.js and package.json live).
- Add the following trigger and pool configuration to the file:
trigger:
- main
pool:
name: Default
What does this code do?
- trigger: - main tells Azure DevOps to automatically run this pipeline whenever code is pushed to the main branch.
- pool: name: Default routes pipeline jobs to your self-hosted agent in the Default pool. This is the same pool you registered your agent to in the previous step.
- Save azure-pipelines.yml.
Add the pipeline steps
The steps section is where you define the actual work the pipeline performs. Each step runs sequentially on your agent.
- In azure-pipelines.yml, add the following steps section directly below the pool block:
steps:
- task: UseNode@1
inputs:
version: '20.x'
displayName: 'Install Node.js'
- script: |
npm install
displayName: 'Install dependencies'
- script: |
npm test
displayName: 'Run tests'
- task: CopyFiles@2
inputs:
sourceFolder: '$(Build.SourcesDirectory)'
contents: |
index.js
package.json
package-lock.json
targetFolder: '$(Build.ArtifactStagingDirectory)'
displayName: 'Copy project files'
- task: PublishPipelineArtifact@1
inputs:
artifactName: 'nodejs-app'
targetPath: '$(Build.ArtifactStagingDirectory)'
displayName: 'Publish pipeline artifact'
What does each step do?
- UseNode@1 downloads and caches Node.js 20.x on the agent. Even though your Mac has Node.js, this ensures the pipeline uses a consistent version.
- npm install installs the project dependencies from package.json.
- npm test runs your test suite. If any test fails, the pipeline stops here.
- CopyFiles@2 selects only the files needed for deployment and copies them to a staging directory.
- PublishPipelineArtifact@1 packages those staged files into a downloadable artifact called nodejs-app. This artifact is your deployable package.
- Save azure-pipelines.yml.
- Confirm your file has both the trigger/pool section at the top and the steps section below it.
YAML looking messy?
YAML is very strict about indentation. Every level uses exactly 2 spaces (not tabs). If your editor shows red squiggles or the file looks off, check that steps: starts at column 0 (no leading spaces) and each - task: or - script: is indented by 2 spaces.
help me fix YAML indentation errors in my azure-pipelines.yml file
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete azure-pipelines.yml file. Make sure your file matches this exactly:
trigger:
- main
pool:
name: Default
steps:
- task: UseNode@1
inputs:
version: '20.x'
displayName: 'Install Node.js'
- script: |
npm install
displayName: 'Install dependencies'
- script: |
npm test
displayName: 'Run tests'
- task: CopyFiles@2
inputs:
sourceFolder: '$(Build.SourcesDirectory)'
contents: |
index.js
package.json
package-lock.json
targetFolder: '$(Build.ArtifactStagingDirectory)'
displayName: 'Copy project files'
- task: PublishPipelineArtifact@1
inputs:
artifactName: 'nodejs-app'
targetPath: '$(Build.ArtifactStagingDirectory)'
displayName: 'Publish pipeline artifact'
Commit and push the pipeline file to GitHub
Azure DevOps looks for the pipeline file in your GitHub repository. You need to push azure-pipelines.yml to the main branch for Azure DevOps to detect it.
Your agent terminal is busy
Your self-hosted agent is running in one terminal window via ./run.sh. Open a new terminal tab or window for the Git commands below. Do not close the agent terminal.
- In a new terminal window, navigate to your project folder and commit the pipeline file by running these commands:
cd ~/azure-pipelines-demo
git add azure-pipelines.yml
git commit -m "Add Azure DevOps pipeline configuration"
git push origin main
What do these commands do?
- git add azure-pipelines.yml stages the new file for commit.
- git commit saves the change to your local repository history.
- git push origin main uploads the commit to your GitHub repository on the main branch.
- Open your GitHub repository in your browser.
- You should see azure-pipelines.yml listed alongside your other project files.
Push rejected or authentication error?
If you see a permission denied or authentication error, GitHub may need you to re-authenticate. Try running git push origin main again and follow any browser prompts that appear.
help me fix a git push authentication error to GitHub
Your pipeline definition is now sitting in GitHub, ready to be picked up by Azure DevOps. Next up, you'll connect your GitHub repo to Azure DevOps and watch the pipeline run for the first time.
Connect GitHub and Run the Pipeline
You have a pipeline definition sitting in your GitHub repository, and a self-hosted agent waiting for work. Now it is time to connect the two inside Azure DevOps.
In this step, you will create a pipeline in Azure DevOps that reads your azure-pipelines.yml file from GitHub. Once connected, Azure DevOps will automatically execute the build on your self-hosted agent.
In this step, get ready to:
- Create a new pipeline in Azure DevOps connected to your GitHub repository.
- Run the pipeline and watch it execute on your self-hosted agent.
- Verify all steps pass and check the published artifact.
Create a new pipeline in Azure DevOps
Azure DevOps needs to know where your code lives and which YAML file defines your pipeline. You will point it at your GitHub repository and let it detect the azure-pipelines.yml file you already pushed.
- In your Azure DevOps project, click Pipelines in the left sidebar.
- Click New pipeline (or Create Pipeline if this is your first pipeline).
- Under Where is your code?, select GitHub.
- Azure DevOps will ask you to authorize the GitHub connection. Click Authorize AzurePipelines and follow the prompts to grant access.
- Select your azure-pipelines-demo repository from the list.
What is the GitHub authorization doing?
When you authorize Azure DevOps to access GitHub, it installs a GitHub App on your account. This gives Azure DevOps read access to your repositories so it can pull your code and detect pipeline YAML files.
- On the Configure your pipeline screen, Azure DevOps detects your existing azure-pipelines.yml file. Select Existing Azure Pipelines YAML file if prompted, or confirm the detected file.
- You should see a preview of your YAML pipeline file. Confirm it matches the azure-pipelines.yml you wrote in the previous step.
Don't see your repository in the list?
Make sure you authorized Azure DevOps to access your GitHub account. If you only granted access to specific repositories, you may need to update the GitHub App permissions to include azure-pipelines-demo.
Help me troubleshoot why my GitHub repository isn't showing up in Azure DevOps
Run the pipeline
With the YAML file detected, you can kick off your first pipeline run. Your self-hosted agent will pick up the job and execute each step.
- Click Run in the top right corner to start the pipeline.
- Azure DevOps will queue the job. Click on the running job to see the live output.
You should see the pipeline execute each step in sequence. In the terminal where your agent is running (the ./run.sh window), you will see activity as the agent picks up and processes the job.
What is happening during the run?
Your self-hosted agent receives the job from Azure DevOps. It clones your GitHub repository, then executes each step defined in your YAML file in order.
- Install Node.js downloads Node.js 20.x to the agent's tool cache.
- Install dependencies runs npm install to fetch Express and generate package-lock.json.
- Run tests executes your three unit tests.
- Copy project files gathers index.js, package.json, and package-lock.json into a staging directory.
- Publish pipeline artifact packages those files as the nodejs-app artifact.
Pipeline stuck on 'Queued' or not starting?
Check that your agent is still running. Switch to the terminal window where you ran ./run.sh and confirm it shows 'Listening for Jobs'. If the terminal was closed, navigate back to ~/myagent and run ./run.sh again.
Also verify the agent appears as Online in Azure DevOps under Organization settings > Agent pools > Default > Agents.
Help me figure out why my Azure DevOps pipeline is stuck in the queue
Verify the pipeline run and check artifacts
A successful pipeline run means every step completed without errors. The published artifact confirms that your app is packaged and ready for deployment.
- Wait for the pipeline run to finish. Each step should show a green checkmark.
- Confirm that all five steps passed: Install Node.js, Install dependencies, Run tests, Copy project files, and Publish pipeline artifact.
- Click on the Run tests step to expand its output. You should see all three tests passing.
- Go back to the pipeline run summary page. Under Related, you should see 1 published artifact listed.
- Click the artifact name (nodejs-app) to view its contents. You should see index.js, package.json, and package-lock.json inside.
What is a pipeline artifact?
A pipeline artifact is a package of files produced by a pipeline run. Think of it as the finished product of your build process.
In a real-world pipeline, this artifact would be passed to a deployment stage that pushes it to a server. You will explore this in the Secret Mission.
Seeing a failed step?
Click on the failed step to see the error output. Common causes include:
- Test failures: check that your index.test.js matches the code from the previous step exactly.
- Install failures: verify your package.json has the correct Express dependency.
- YAML syntax errors: check for incorrect indentation in your azure-pipelines.yml file. YAML is whitespace-sensitive.
Help me debug a failed step in my Azure DevOps pipeline
Your pipeline is alive. Every time code hits the main branch, Azure DevOps will automatically build, test, and package your app. Next up, you will prove this by pushing a code change and watching the pipeline trigger on its own.
Trigger the Pipeline with a Code Change
In the last step, you manually ran your pipeline and watched it build, test, and package your app. That proved the pipeline works.
Now it's time to see the real power of CI/CD: automatic triggers. You'll add a new feature to your app, push it to GitHub, and watch the pipeline kick off on its own. No manual button clicks required.
In this step, get ready to:
- Add a new route and test to your Node.js app.
- Push the changes to GitHub.
- Verify the pipeline auto-triggers, all tests pass, and the artifact is published.
Add a new route to your Express app
To prove that your pipeline responds to code changes, you need an actual change to push. You'll add a /status endpoint that returns the app's version info.
- Open index.js in your code editor.
- Find the /health route. Directly below its closing });, add this new route:
app.get('/status', (req, res) => {
res.json({ status: 'running', version: '1.0.0' });
});
What does this route do?
This adds a GET /status endpoint that returns the app's current status and version number as JSON. It's a lightweight addition that gives your pipeline a real code change to detect.
- Save index.js.
- Run your tests locally to confirm nothing broke:
npm test
You should see 3 tests passing (the existing ones). Your new route doesn't break anything, but it doesn't have its own test yet.
Tests failing?
Make sure your new route is placed between the /health route and the if (require.main === module) block. Check for missing commas, parentheses, or curly braces.
Help me debug my failing tests in index.js
Add a test for the new route
A good CI pipeline catches bugs before they reach production. Adding a test for your new route means the pipeline will verify it works on every future push.
- Open index.test.js in your code editor.
- At the bottom of the file, below the last test() block, add this new test:
test('app has GET /status route', () => {
const routes = app._router.stack.filter(r => r.route);
const statusRoute = routes.find(r => r.route.path === '/status');
assert.ok(statusRoute, 'GET /status route should exist');
});
What does this test do?
This test checks that your Express app has a registered route at /status. It uses the same pattern as your existing tests: filtering the router stack for route entries and asserting the expected path exists.
- Save index.test.js.
- Run the tests again to confirm all four pass:
npm test
You should now see 4 tests passing, including your new app has GET /status route test.
Only seeing 3 tests?
Make sure you saved both index.js (with the new route) and index.test.js (with the new test). The test imports index.js, so both files need to be saved before running npm test.
Help me figure out why my new test isn't showing up
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here's what your index.js should look like now:
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({ message: 'Hello from Azure Pipelines!' });
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
app.get('/status', (req, res) => {
res.json({ status: 'running', version: '1.0.0' });
});
if (require.main === module) {
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
}
module.exports = app;
And here's your index.test.js:
const { test } = require('node:test');
const assert = require('node:assert');
const app = require('./index');
test('app module exports an express app', () => {
assert.strictEqual(typeof app, 'function');
});
test('app has GET / route', () => {
const routes = app._router.stack.filter(r => r.route);
const homeRoute = routes.find(r => r.route.path === '/');
assert.ok(homeRoute, 'GET / route should exist');
});
test('app has GET /health route', () => {
const routes = app._router.stack.filter(r => r.route);
const healthRoute = routes.find(r => r.route.path === '/health');
assert.ok(healthRoute, 'GET /health route should exist');
});
test('app has GET /status route', () => {
const routes = app._router.stack.filter(r => r.route);
const statusRoute = routes.find(r => r.route.path === '/status');
assert.ok(statusRoute, 'GET /status route should exist');
});
Push changes and watch the pipeline trigger
Your pipeline's trigger is set to fire on any push to the main branch. Pushing your code change to GitHub is all it takes to start a new pipeline run.
- Stage, commit, and push your changes by running these commands:
git add .
git commit -m "Add /status route and test"
git push
What does this trigger?
The moment your push reaches GitHub, Azure DevOps detects the new commit on main and automatically queues a new pipeline run. Your self-hosted agent picks up the job and executes all the steps: install, test, copy, and publish.
- Switch to your browser and open your Azure DevOps project.
- Click Pipelines in the left sidebar.
- You should see a new run in progress (or recently completed). Click on it to open the run details.
The run was triggered by your push. You'll see the commit message Add /status route and test in the run summary.
- Click on the job to see the step-by-step logs.
- Expand the Run tests step. You should see all 4 tests passing, including your new app has GET /status route test.
- Go back to the run summary and confirm the nodejs-app artifact is published.
Why does this matter?
This is the full CI loop in action. Every time anyone pushes code to your repository, the pipeline automatically builds, tests, and packages the app. Bugs get caught immediately, and every successful run produces a deployable artifact.
Pipeline didn't trigger?
Make sure your self-hosted agent is still running. Check the terminal window where you ran ./run.sh. If it stopped, restart it from the ~/myagent directory.
Also confirm your push went to the main branch. Run git branch to check which branch you're on. The trigger in azure-pipelines.yml only watches the main branch.
Help me figure out why my pipeline didn't auto-trigger
Secret mission
Add a Deployment Stage
Your pipeline builds, tests, and packages your app. But the artifact just sits there. In this secret mission, you will add a Deploy stage that takes that artifact and ships it live to Azure App Service. Push code, and your app deploys itself.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on free-tier resources and a local self-hosted agent. There are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Azure DevOps organization and project (free tier, no cost).
- Self-hosted pipeline agent running on your Mac (free, local process).
- GitHub repository with your Node.js app.
- Local Node.js project files on your Desktop.
- Personal Access Token (PAT) in Azure DevOps.
- Secret Mission only: Azure App Service (Free F1 tier, no cost unless upgraded).
Keep everything running
No action needed. Choose this if you are still actively building or plan to keep pushing code to trigger your pipeline.
- Your self-hosted agent only consumes resources while a pipeline job is actively running.
- Your Azure DevOps project and pipeline definition stay active at no cost.
- Leave the terminal window running ./run.sh open so the agent can pick up new jobs.
Pause - I'll come back to this later
Shut down the running agent to free up your terminal. Your pipeline definition stays in Azure DevOps, but jobs will queue until you restart the agent.
- Press Ctrl+C in the terminal window running ./run.sh to stop the agent.
- To restart later, open a terminal, navigate to ~/myagent, and run ./run.sh again.
- Your Azure DevOps project, pipeline YAML, and GitHub repo all remain intact.
Delete - I don't want to use this again
Remove all project resources and start fresh. This unregisters your agent and cleans up local files.
- Press Ctrl+C in the terminal running the agent to stop it.
- Unregister the agent by running this command inside ~/myagent:
./config.sh remove
- Delete the agent directory by running:
rm -rf ~/myagent
- Optionally delete the local project folder from your Desktop.
- Optionally delete the GitHub repository from your GitHub account settings.
- Optionally delete the Azure DevOps project by going to Project settings > Overview > Delete.
- Revoke your Personal Access Token in Azure DevOps under your profile icon > Personal access tokens.
- Secret Mission only: Delete your Azure App Service in the Azure Portal to avoid any future charges if you upgrade the tier.
That's a Wrap!
That's a Wrap!
Nice work. You just built a fully automated CI/CD pipeline using Azure DevOps that builds, tests, and packages a Node.js application every time you push code to GitHub.
You've learned how to:
- Installed and configured a self-hosted pipeline agent on your Mac to execute build jobs from Azure DevOps.
- Written a YAML pipeline that automatically installs dependencies, runs tests, and publishes a build artifact on every push to main.
- Triggered the full CI pipeline with a code change and verified it ran end-to-end, from GitHub push to published artifact.
- Added an automated deployment stage that deploys your app to Azure App Service, completing the full CI/CD loop.
Ready to quiz yourself?