Deploy a CRUD App to Azure with CI/CD

Build and deploy a full-stack shopping app to Azure with automated CI/CD.

Introduction

30 Second Summary

Every time you use an online store, someone on the engineering team pushed a code change that went live without you ever noticing. That seamless process is powered by CI/CD pipelines that automatically build, test, and deploy code the moment a developer hits "push."

In this project, you will build a full-stack shopping catalog application and deploy it to Azure with a fully automated CI/CD pipeline. Every time you push code to GitHub, your changes will go live automatically.

What You'll Build

A product catalog shopping application with a Node.js/Express backend, a responsive HTML/CSS/JavaScript frontend, and an Azure Cosmos DB database, deployed to Azure App Service with a GitHub Actions workflow that auto-deploys on every push.

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

  • A REST API with full create, read, update, and delete endpoints for managing products.
  • A cloud-hosted database on Azure Cosmos DB storing your product data persistently.
  • A live public URL on Azure App Service where your shopping catalog is accessible to anyone.
  • An automated CI/CD pipeline using GitHub Actions that deploys your code changes on every push to main.
  • Secret Mission: Add product search and category filtering to your shopping catalog.

Are there any prerequisites?

You will need an Azure account (free tier eligible) and a GitHub account. No prior experience with Azure or CI/CD is required.

Before We Start

Before you can build and deploy your shopping app, you need a few tools installed on your machine. These tools let you write code locally, communicate with Azure's cloud services, and push your work to GitHub.

In this step, you will install Node.js (the runtime that powers your app), the Azure CLI (for managing cloud resources from your terminal), and a code editor. You will also confirm that Git is ready for version control.

In this step, get ready to:
  • Install and verify Node.js 22 LTS.
  • Install Azure CLI and log in to your Azure account.
  • Install Visual Studio Code and confirm Git is ready.
Install Node.js 22 LTS

Node.js is the JavaScript runtime your Express server will run on. This project requires version 22 or higher.

  • Open a terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check if Node.js is installed by running this command:
node --version

✔️ I see version 22 or higher

Your output shows something like v22.22.2 or higher. Node.js is ready.

  • Confirm npm is also available by running:
npm --version

You should see a version number like 10.9.8 or similar. npm comes bundled with Node.js.

ⓧ I see an older version

Your system has a version of Node.js below 22. You need to upgrade to version 22 LTS.

  • Visit the Node.js 22 download page.
  • Download the LTS installer for your operating system (.pkg for macOS, .msi for Windows).
  • Run the installer and follow the prompts. The installer will replace your current version.
  • Close and reopen your terminal, then run node --version again to confirm it shows v22 or higher.

macOS users with Homebrew

If you use Homebrew, you can also upgrade with:

brew install node@22

ⓧ Command not found

Node.js is not installed on your machine yet.

  • Visit the Node.js 22 download page.
  • Download the LTS installer for your operating system (.pkg for macOS, .msi for Windows).
  • Run the installer and follow the on-screen prompts. Accept the default settings.
  • Close and reopen your terminal after installation.
  • Run node --version to confirm it shows v22 or higher.
  • Run npm --version to confirm npm is also available.

What is Node.js?

Node.js is a JavaScript runtime that lets you run JavaScript outside of a web browser. Your Express server and all its dependencies run on Node.js.

npm (Node Package Manager) comes bundled with Node.js. You will use it to install libraries like Express and the Azure Cosmos DB SDK.

Install Azure CLI and log in

The Azure CLI lets you create and manage cloud resources directly from your terminal. You will use it to provision your database and deploy your app.

  • Check if Azure CLI is already installed by running:
az --version

✔️ I see a version number

Your output shows the Azure CLI version (e.g. azure-cli 2.86.0 or similar). You are good to go.

ⓧ Command not found

Azure CLI is not installed yet. Follow the instructions for your operating system.

macOS

  • Install Azure CLI using Homebrew by running:
brew update && brew install azure-cli

If you do not have Homebrew, visit the Azure CLI install guide for alternative methods.

Windows

  • Install Azure CLI using WinGet by running this in PowerShell:
winget install -e --id Microsoft.AzureCLI

After installation, close and reopen your terminal for the az command to become available.

  • After installing, run az --version again to confirm it works.

Now log in to your Azure account so the CLI can manage resources on your behalf.

  • Log in to Azure by running:
az login

What happens when you run az login?

A browser window will open asking you to sign in with your Azure account. After you sign in, the browser confirms success and your terminal displays your subscription details.

If no browser opens, the terminal displays a device code. Visit the URL shown and enter that code to complete sign-in.

  • Sign in with the same Microsoft account you used to create your Azure subscription.
  • After signing in, your terminal shows a JSON list of your subscriptions. This confirms the CLI is authenticated.

Login not working?

  • Make sure you have an active Azure subscription. Free accounts work fine.
  • If the browser does not open, try running az login --use-device-code instead.

Help me troubleshoot my Azure CLI login

Install Visual Studio Code and verify Git

You need a code editor to write your application and Git to push your code to GitHub.

  • If you do not already have Visual Studio Code (or Cursor), download it from code.visualstudio.com.
  • Run the installer and follow the on-screen prompts.
  • Once installed, open VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type Visual Studio Code, and press Enter.

Now confirm Git is installed. Git usually comes pre-installed on macOS and is included with the VS Code installer on Windows.

  • Back in your terminal, check your Git version by running:
git --version

✔️ I see a version number

Your output shows something like git version 2.x.x. Git is ready.

ⓧ Command not found

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

  • Download Git from git-scm.com.
  • Run the installer and accept the default settings.
  • Close and reopen your terminal, then run git --version again to confirm it works.

What is Git?

Git is a version control system that tracks changes to your code. You will use it to push your project to GitHub, which triggers your CI/CD pipeline to automatically deploy changes.

Your development environment is ready. Next up, you will build the Express backend that powers your shopping catalog.

Build the Express Backend with CRUD API

Your development environment is ready. Now it is time to build the actual application.

Every shopping catalog needs a way to create, read, update, and delete products. You will build a REST API using Express that handles all four operations. For now, the API stores data in memory so you can test everything locally before connecting a real database.

In this step, get ready to:
  • Initialize a Node.js project and install dependencies.
  • Create an Express server with four CRUD endpoints.
  • Test the API locally using in-memory data storage.
Initialize the Node.js project and install dependencies

Before writing any application code, you need a project folder with a package.json file. This file tracks your project's name, entry point, and dependencies.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir azure-shopping-app
cd azure-shopping-app
  • Initialize a new Node.js project by running:
npm init -y

This creates a default package.json in your folder.

  • Open the project in VS Code by running:
code .

VS Code didn't open?

If code . is not recognized, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type Visual Studio Code, and press Enter. Then use File > Open Folder to select your azure-shopping-app folder on the Desktop.

help me set up the VS Code code command in my terminal

  • Open package.json in VS Code's Explorer sidebar and replace all of its contents with the following:
{
  "name": "azure-shopping-app",
  "version": "1.0.0",
  "description": "A CRUD shopping catalog app deployed to Azure",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "4.21.2",
    "@azure/cosmos": "4.2.0"
  }
}

What does this file do?

  • "main": "server.js" tells Node.js which file to run when the app starts.
  • "start": "node server.js" defines a shortcut so you can launch the app with npm start.
  • The dependencies section pins the exact versions of Express (your web framework) and the Azure Cosmos DB SDK (used in a later step).
  • Save package.json.
  • Install the dependencies by running this command in your terminal:
npm install
  • Confirm the packages installed by running:
ls node_modules

You should see a list of folders including express and @azure in the output.

Seeing errors during npm install?

Make sure you saved package.json before running npm install. Check that the JSON has no trailing commas or missing quotes.

help me fix npm install errors in my azure-shopping-app project

Create the Express server with CRUD endpoints

Now you will create the server file that handles HTTP requests. The server uses Express to define four API routes for managing products.

  • Create a new file called server.js in the root of your azure-shopping-app folder.
  • In VS Code, click File > New File, then File > Save As and name it server.js.
  • Add the server setup and database configuration by pasting this code into server.js:
const express = require('express');
const path = require('path');
const { CosmosClient } = require('@azure/cosmos');

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

app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

// Cosmos DB configuration
const endpoint = process.env.COSMOS_ENDPOINT;
const key = process.env.COSMOS_KEY;
const databaseId = 'shoppingdb';
const containerId = 'products';

let container;

async function initializeDatabase() {
  if (!endpoint || !key) {
    console.log('Cosmos DB not configured. Using in-memory storage.');
    return;
  }
  const client = new CosmosClient({ endpoint, key });
  const { database } = await client.databases.createIfNotExists({ id: databaseId });
  const { container: cosmosContainer } = await database.containers.createIfNotExists({
    id: containerId,
    partitionKey: { paths: ['/id'] }
  });
  container = cosmosContainer;
  console.log('Connected to Cosmos DB');
}

// In-memory fallback for local development
let products = [];
let nextId = 1;

What does this code do?

  • express.json() is middleware that parses incoming JSON request bodies so your endpoints can read them.
  • express.static() serves frontend files from a public folder. You will create this folder in the next step.
  • initializeDatabase() checks for Cosmos DB credentials. If none are set (like right now), it falls back to in-memory storage.
  • The products array acts as temporary storage while you develop locally.

You will not see a change yet. The next chunks add the API endpoints that make the server useful.

  • Add the GET and POST endpoints below the in-memory fallback section in server.js:
// GET all products
app.get('/api/products', async (req, res) => {
  try {
    if (container) {
      const { resources } = await container.items.readAll().fetchAll();
      res.json(resources);
    } else {
      res.json(products);
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// POST create a product
app.post('/api/products', async (req, res) => {
  try {
    const { name, price, description } = req.body;
    if (!name || !price) {
      return res.status(400).json({ error: 'Name and price are required' });
    }
    const product = {
      id: Date.now().toString(),
      name,
      price: parseFloat(price),
      description: description || ''
    };
    if (container) {
      const { resource } = await container.items.create(product);
      res.status(201).json(resource);
    } else {
      products.push(product);
      res.status(201).json(product);
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

What do these endpoints do?

  • GET /api/products returns all products. If Cosmos DB is connected, it reads from the database. Otherwise, it returns the in-memory array.
  • POST /api/products creates a new product from the request body. It validates that name and price are provided before saving.

You will not see a change yet. The next chunk adds the update and delete endpoints.

  • Add the PUT and DELETE endpoints below the POST endpoint in server.js:
// PUT update a product
app.put('/api/products/:id', async (req, res) => {
  try {
    const { id } = req.params;
    const { name, price, description } = req.body;
    if (container) {
      const { resource } = await container.item(id, id).read();
      if (!resource) {
        return res.status(404).json({ error: 'Product not found' });
      }
      const updated = { ...resource, name, price: parseFloat(price), description };
      const { resource: result } = await container.item(id, id).replace(updated);
      res.json(result);
    } else {
      const index = products.findIndex(p => p.id === id);
      if (index === -1) {
        return res.status(404).json({ error: 'Product not found' });
      }
      products[index] = { ...products[index], name, price: parseFloat(price), description };
      res.json(products[index]);
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// DELETE a product
app.delete('/api/products/:id', async (req, res) => {
  try {
    const { id } = req.params;
    if (container) {
      await container.item(id, id).delete();
      res.status(204).send();
    } else {
      products = products.filter(p => p.id !== id);
      res.status(204).send();
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Initialize and start
initializeDatabase().then(() => {
  app.listen(port, () => {
    console.log(`Server running on port ${port}`);
  });
});

What do these endpoints do?

  • PUT /api/products/:id updates an existing product by its ID. It reads the current product, merges in the new values, and saves it back.
  • DELETE /api/products/:id removes a product by its ID and returns a 204 (no content) response.
  • The final block calls initializeDatabase() then starts listening on port 3000. Since no Cosmos DB credentials are set yet, you will see the in-memory fallback message.
  • Save server.js.

Seeing red underlines in VS Code?

Red underlines usually mean a syntax error. Check for missing closing braces }, missing commas between object properties, or unmatched parentheses (). Every opening brace and parenthesis must have a matching close.

help me fix syntax errors in my server.js Express file

✔️ Awesome, I've got everything!

Great. Make sure you have saved both package.json and server.js.

ⓧ I'd like to double check the full code

Here is the complete package.json:

{
  "name": "azure-shopping-app",
  "version": "1.0.0",
  "description": "A CRUD shopping catalog app deployed to Azure",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "4.21.2",
    "@azure/cosmos": "4.2.0"
  }
}

Here is the complete server.js:

const express = require('express');
const path = require('path');
const { CosmosClient } = require('@azure/cosmos');

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

app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

// Cosmos DB configuration
const endpoint = process.env.COSMOS_ENDPOINT;
const key = process.env.COSMOS_KEY;
const databaseId = 'shoppingdb';
const containerId = 'products';

let container;

async function initializeDatabase() {
  if (!endpoint || !key) {
    console.log('Cosmos DB not configured. Using in-memory storage.');
    return;
  }
  const client = new CosmosClient({ endpoint, key });
  const { database } = await client.databases.createIfNotExists({ id: databaseId });
  const { container: cosmosContainer } = await database.containers.createIfNotExists({
    id: containerId,
    partitionKey: { paths: ['/id'] }
  });
  container = cosmosContainer;
  console.log('Connected to Cosmos DB');
}

// In-memory fallback for local development
let products = [];
let nextId = 1;

// GET all products
app.get('/api/products', async (req, res) => {
  try {
    if (container) {
      const { resources } = await container.items.readAll().fetchAll();
      res.json(resources);
    } else {
      res.json(products);
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// POST create a product
app.post('/api/products', async (req, res) => {
  try {
    const { name, price, description } = req.body;
    if (!name || !price) {
      return res.status(400).json({ error: 'Name and price are required' });
    }
    const product = {
      id: Date.now().toString(),
      name,
      price: parseFloat(price),
      description: description || ''
    };
    if (container) {
      const { resource } = await container.items.create(product);
      res.status(201).json(resource);
    } else {
      products.push(product);
      res.status(201).json(product);
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// PUT update a product
app.put('/api/products/:id', async (req, res) => {
  try {
    const { id } = req.params;
    const { name, price, description } = req.body;
    if (container) {
      const { resource } = await container.item(id, id).read();
      if (!resource) {
        return res.status(404).json({ error: 'Product not found' });
      }
      const updated = { ...resource, name, price: parseFloat(price), description };
      const { resource: result } = await container.item(id, id).replace(updated);
      res.json(result);
    } else {
      const index = products.findIndex(p => p.id === id);
      if (index === -1) {
        return res.status(404).json({ error: 'Product not found' });
      }
      products[index] = { ...products[index], name, price: parseFloat(price), description };
      res.json(products[index]);
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// DELETE a product
app.delete('/api/products/:id', async (req, res) => {
  try {
    const { id } = req.params;
    if (container) {
      await container.item(id, id).delete();
      res.status(204).send();
    } else {
      products = products.filter(p => p.id !== id);
      res.status(204).send();
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Initialize and start
initializeDatabase().then(() => {
  app.listen(port, () => {
    console.log(`Server running on port ${port}`);
  });
});
Test the API locally

Your server code is complete. Time to start it up and confirm the CRUD operations work.

  • Start the server by running:
npm start

You should see two lines in your terminal:

Cosmos DB not configured. Using in-memory storage.
Server running on port 3000

Why the Cosmos DB message?

You have not set the COSMOS_ENDPOINT and COSMOS_KEY environment variables yet. The server detects this and uses the in-memory array instead. You will connect the real database in a later step.

  • Open a second terminal tab or window. In VS Code, click the + icon in the terminal panel to open a new terminal.
  • Test creating a product by running:
curl -X POST http://localhost:3000/api/products -H "Content-Type: application/json" -d '{"name": "Test Product", "price": 9.99, "description": "A test item"}'

You should see a JSON response with your product, including an auto-generated id field.

  • Verify the product was saved by running:
curl http://localhost:3000/api/products

You should see a JSON array containing the product you just created.

Seeing "Connection refused" or no output?

Make sure your server is still running in the other terminal tab. If it crashed, check for syntax errors in server.js and run npm start again.

On Windows, if curl is not recognized, use PowerShell's Invoke-WebRequest or install curl separately.

help me troubleshoot my Express server not responding to curl requests

  • Stop the server by pressing Ctrl+C in the terminal where it is running.

Your backend API is fully functional with in-memory storage. Next up, you will build the frontend interface that connects to these endpoints so users can manage products through a browser.

Build the Frontend Interface

Your Express API is running and ready to handle requests. Now you need a way for users to actually interact with it.

In this step, you will create the frontend that talks to your API. This means building an HTML page with a product form, styling it with CSS, and writing JavaScript that sends requests to your CRUD endpoints.

In this step, get ready to:
  • Create a static HTML page with a product form and product list display.
  • Add CSS styling for a clean, responsive shopping catalog layout.
  • Write frontend JavaScript to call the API endpoints and render products dynamically.
Create the HTML page

Your Express server already serves static files from a public/ folder. You just need to create that folder and add your HTML file to it.

  • Create the public directory inside your azure-shopping-app folder by running this command:
mkdir public
  • Confirm the folder exists by running:
ls

You should see public listed alongside server.js, package.json, and node_modules.

  • Create a new file called index.html inside the public/ folder and paste the following code into it:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Shopping Catalog</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>Shopping Catalog</h1>

    <form id="product-form">
      <input type="hidden" id="product-id">
      <div class="form-group">
        <input type="text" id="product-name" placeholder="Product name" required>
      </div>
      <div class="form-group">
        <input type="number" id="product-price" placeholder="Price" step="0.01" required>
      </div>
      <div class="form-group">
        <input type="text" id="product-description" placeholder="Description">
      </div>
      <button type="submit" id="submit-btn">Add Product</button>
      <button type="button" id="cancel-btn" class="hidden">Cancel</button>
    </form>

    <div id="product-list"></div>
  </div>

  <script src="app.js"></script>
</body>
</html>

What does this HTML do?

The page has two main sections. The <form> collects product data (name, price, description) and has a hidden product-id field that tracks whether you are creating or editing a product.

The empty product-list div is where JavaScript will dynamically render product cards. The cancel-btn starts hidden and only appears when you enter edit mode.

  • Save public/index.html.
  • Start the server by running:
node server.js
  • Open your browser and navigate to http://localhost:3000. You should see the "Shopping Catalog" heading and a basic unstyled form with input fields.

Seeing a blank page?

Make sure your file is saved inside the public/ folder, not in the project root. Express only serves files from the public directory.

Also check that the server is running. If you see an error in the terminal, stop the server with Ctrl+C and restart it.

Why is my HTML page not loading at localhost:3000?

Add CSS styling

The form works but looks bare. A stylesheet will give the page a clean, professional layout.

  • Stop the server with Ctrl+C in your terminal.
  • Create a new file called style.css inside the public/ folder and paste the following code into it:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f5f5f5;
  padding: 20px;
}

.container {
  max-width: 800px;
  margin: 0 auto;
}

h1 {
  text-align: center;
  color: #333;
  margin-bottom: 30px;
}

form {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin-bottom: 30px;
}

.form-group {
  margin-bottom: 15px;
}

input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

button {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  margin-right: 10px;
}

#submit-btn {
  background-color: #0078d4;
  color: white;
}

#submit-btn:hover {
  background-color: #006abc;
}

#cancel-btn {
  background-color: #666;
  color: white;
}

.hidden {
  display: none;
}

.product-card {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin-bottom: 15px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.product-info h3 {
  color: #333;
  margin-bottom: 5px;
}

.product-info p {
  color: #666;
  margin-bottom: 3px;
}

.product-info .price {
  color: #0078d4;
  font-weight: bold;
  font-size: 18px;
}

.product-actions button {
  padding: 8px 15px;
  font-size: 14px;
  margin-left: 5px;
}

.edit-btn {
  background-color: #ffc107;
  color: #333;
}

.delete-btn {
  background-color: #dc3545;
  color: white;
}

What does this stylesheet do?

The CSS uses a centered .container layout with a max width of 800px. The form gets a white card appearance with rounded corners and a subtle shadow.

The .product-card class uses flexbox to position product info on the left and action buttons on the right. The .hidden class lets JavaScript toggle the cancel button's visibility.

  • Save public/style.css.
  • Start the server again by running:
node server.js
  • Refresh http://localhost:3000 in your browser. You should see a clean white form card with a blue "Add Product" button on a light gray background.

Form still looks unstyled?

Check that the file is named exactly style.css (not styles.css). The HTML references style.css in the <link> tag, so the filename must match exactly.

Why isn't my CSS loading in the browser?

Write frontend JavaScript to connect the UI to your API

The page looks good, but nothing happens when you submit the form. You need JavaScript that sends requests to your Express API and updates the page with product data.

  • Stop the server with Ctrl+C.
  • Create a new file called app.js inside the public/ folder. Start by adding the DOM references and display functions:
const form = document.getElementById('product-form');
const productIdInput = document.getElementById('product-id');
const nameInput = document.getElementById('product-name');
const priceInput = document.getElementById('product-price');
const descriptionInput = document.getElementById('product-description');
const submitBtn = document.getElementById('submit-btn');
const cancelBtn = document.getElementById('cancel-btn');
const productList = document.getElementById('product-list');

// Fetch all products from the API and display them
async function loadProducts() {
  const response = await fetch('/api/products');
  const products = await response.json();
  renderProducts(products);
}

// Build HTML cards for each product
function renderProducts(products) {
  productList.innerHTML = products.map(product => `
    <div class="product-card">
      <div class="product-info">
        <h3>${product.name}</h3>
        <p class="price">$${product.price.toFixed(2)}</p>
        <p>${product.description}</p>
      </div>
      <div class="product-actions">
        <button class="edit-btn" onclick="editProduct('${product.id}', '${product.name}', ${product.price}, '${product.description}')">Edit</button>
        <button class="delete-btn" onclick="deleteProduct('${product.id}')">Delete</button>
      </div>
    </div>
  `).join('');
}

What does this code do?

The first eight lines grab references to every DOM element the script will interact with. Caching these avoids repeated lookups.

The loadProducts function calls your GET /api/products endpoint and passes the result to renderProducts. The render function builds product cards using a template literal and injects them into the page.

  • Now add the interaction logic below the renderProducts function in the same file:
// Handle form submission for both create and update
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const id = productIdInput.value;
  const product = {
    name: nameInput.value,
    price: priceInput.value,
    description: descriptionInput.value
  };

  if (id) {
    // Update existing product
    await fetch(`/api/products/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(product)
    });
  } else {
    // Create new product
    await fetch('/api/products', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(product)
    });
  }

  resetForm();
  loadProducts();
});

// Populate the form with existing product data for editing
function editProduct(id, name, price, description) {
  productIdInput.value = id;
  nameInput.value = name;
  priceInput.value = price;
  descriptionInput.value = description;
  submitBtn.textContent = 'Update Product';
  cancelBtn.classList.remove('hidden');
}

// Delete a product after confirmation
async function deleteProduct(id) {
  if (confirm('Are you sure you want to delete this product?')) {
    await fetch(`/api/products/${id}`, { method: 'DELETE' });
    loadProducts();
  }
}

// Reset form back to "Add" mode
cancelBtn.addEventListener('click', resetForm);

function resetForm() {
  productIdInput.value = '';
  nameInput.value = '';
  priceInput.value = '';
  descriptionInput.value = '';
  submitBtn.textContent = 'Add Product';
  cancelBtn.classList.add('hidden');
}

// Load products on page load
loadProducts();

What does this code do?

The form submit handler checks if productIdInput has a value. If it does, the form is in edit mode and sends a PUT request. Otherwise, it sends a POST to create a new product.

The editProduct function pre-fills the form and reveals the cancel button. The deleteProduct function confirms with the user, then sends a DELETE request. Finally, loadProducts() at the bottom fetches products as soon as the page loads.

  • Save public/app.js.
  • Start the server by running:
node server.js
  • Refresh http://localhost:3000 in your browser.
  • Type Test Product in the name field, 9.99 in the price field, and A sample item in the description field.
  • Click Add Product. You should see a product card appear below the form with the name, price, and description displayed.
  • Click Edit on the product card. The form should fill with the product data and the button should change to "Update Product".
  • Click Delete and confirm the prompt. The product card should disappear.

Products not appearing after clicking Add Product?

Open your browser's developer tools (F12 or Cmd+Option+I on macOS) and check the Console tab for errors. A common issue is a typo in the element IDs. Make sure product-form, product-name, and product-price in your HTML match exactly what the JavaScript references.

Help me debug why my frontend form isn't creating products

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in your public/ folder.

ⓧ I'd like to double check the full code

Here is the complete code for all three frontend files.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Shopping Catalog</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>Shopping Catalog</h1>

    <form id="product-form">
      <input type="hidden" id="product-id">
      <div class="form-group">
        <input type="text" id="product-name" placeholder="Product name" required>
      </div>
      <div class="form-group">
        <input type="number" id="product-price" placeholder="Price" step="0.01" required>
      </div>
      <div class="form-group">
        <input type="text" id="product-description" placeholder="Description">
      </div>
      <button type="submit" id="submit-btn">Add Product</button>
      <button type="button" id="cancel-btn" class="hidden">Cancel</button>
    </form>

    <div id="product-list"></div>
  </div>

  <script src="app.js"></script>
</body>
</html>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f5f5f5;
  padding: 20px;
}

.container {
  max-width: 800px;
  margin: 0 auto;
}

h1 {
  text-align: center;
  color: #333;
  margin-bottom: 30px;
}

form {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin-bottom: 30px;
}

.form-group {
  margin-bottom: 15px;
}

input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

button {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  margin-right: 10px;
}

#submit-btn {
  background-color: #0078d4;
  color: white;
}

#submit-btn:hover {
  background-color: #006abc;
}

#cancel-btn {
  background-color: #666;
  color: white;
}

.hidden {
  display: none;
}

.product-card {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin-bottom: 15px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.product-info h3 {
  color: #333;
  margin-bottom: 5px;
}

.product-info p {
  color: #666;
  margin-bottom: 3px;
}

.product-info .price {
  color: #0078d4;
  font-weight: bold;
  font-size: 18px;
}

.product-actions button {
  padding: 8px 15px;
  font-size: 14px;
  margin-left: 5px;
}

.edit-btn {
  background-color: #ffc107;
  color: #333;
}

.delete-btn {
  background-color: #dc3545;
  color: white;
}
const form = document.getElementById('product-form');
const productIdInput = document.getElementById('product-id');
const nameInput = document.getElementById('product-name');
const priceInput = document.getElementById('product-price');
const descriptionInput = document.getElementById('product-description');
const submitBtn = document.getElementById('submit-btn');
const cancelBtn = document.getElementById('cancel-btn');
const productList = document.getElementById('product-list');

async function loadProducts() {
  const response = await fetch('/api/products');
  const products = await response.json();
  renderProducts(products);
}

function renderProducts(products) {
  productList.innerHTML = products.map(product => `
    <div class="product-card">
      <div class="product-info">
        <h3>${product.name}</h3>
        <p class="price">$${product.price.toFixed(2)}</p>
        <p>${product.description}</p>
      </div>
      <div class="product-actions">
        <button class="edit-btn" onclick="editProduct('${product.id}', '${product.name}', ${product.price}, '${product.description}')">Edit</button>
        <button class="delete-btn" onclick="deleteProduct('${product.id}')">Delete</button>
      </div>
    </div>
  `).join('');
}

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const id = productIdInput.value;
  const product = {
    name: nameInput.value,
    price: priceInput.value,
    description: descriptionInput.value
  };

  if (id) {
    await fetch(`/api/products/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(product)
    });
  } else {
    await fetch('/api/products', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(product)
    });
  }

  resetForm();
  loadProducts();
});

function editProduct(id, name, price, description) {
  productIdInput.value = id;
  nameInput.value = name;
  priceInput.value = price;
  descriptionInput.value = description;
  submitBtn.textContent = 'Update Product';
  cancelBtn.classList.remove('hidden');
}

async function deleteProduct(id) {
  if (confirm('Are you sure you want to delete this product?')) {
    await fetch(`/api/products/${id}`, { method: 'DELETE' });
    loadProducts();
  }
}

cancelBtn.addEventListener('click', resetForm);

function resetForm() {
  productIdInput.value = '';
  nameInput.value = '';
  priceInput.value = '';
  descriptionInput.value = '';
  submitBtn.textContent = 'Add Product';
  cancelBtn.classList.add('hidden');
}

loadProducts();

Your shopping app is fully functional locally. You can add, view, edit, and delete products right in the browser. Next up, you will connect a real database so your products persist even when the server restarts.

Connect Azure Cosmos DB

Your shopping app works locally, but the product data lives in memory. Every time you restart the server, the data disappears.

To make data persistent, you need a real database. In this step, you will create an Azure Cosmos DB account on Azure's free tier and connect your Express app to it.

In this step, get ready to:
  • Create an Azure Cosmos DB account with the free tier using Azure CLI.
  • Install the @azure/cosmos npm package and retrieve connection credentials.
  • Test your app connecting to Cosmos DB locally.
Create the Azure Cosmos DB account

Cosmos DB is Azure's globally distributed NoSQL database. The free tier gives you 1000 RU/s of throughput and 25 GB of storage for the lifetime of your account.

  • Create a resource group to hold all your Azure resources by running this command:
az group create --name shopping-app-rg --location eastus

What is a resource group?

A resource group is a logical container in Azure that holds related resources together. It makes it easy to manage and delete everything at once when you are done.

  • You should see JSON output confirming the resource group was created with "provisioningState": "Succeeded".
  • Create the Cosmos DB account on the free tier by running this command:
az cosmosdb create \
  -n shopping-app-cosmos \
  -g shopping-app-rg \
  --enable-free-tier true \
  --default-consistency-level Session

What does this command do?

This creates a Cosmos DB account named shopping-app-cosmos in your resource group. The --enable-free-tier true flag opts you into the lifetime free tier (1000 RU/s, 25 GB). Session consistency means your app always sees its own writes.

This command takes 3-5 minutes to complete. Wait for it to finish before moving on.

  • You should see a large JSON response. Look for "provisioningState": "Succeeded" in the output.

Seeing a free tier error?

You can only have one free tier Cosmos DB account per Azure subscription. If you already have one, either use that existing account or remove the --enable-free-tier true flag (standard pricing applies instead).

If the account name is already taken, choose a different name (it must be globally unique, lowercase, and fewer than 44 characters).

Help me fix my Cosmos DB creation error.

Install the SDK and configure credentials

Your package.json already lists @azure/cosmos as a dependency. You need to install it locally and then retrieve the connection credentials from Azure.

  • Install the Cosmos DB SDK by running:
npm install
  • You should see @azure/cosmos appear in the terminal output as an added package.
  • Retrieve your Cosmos DB endpoint by running:
az cosmosdb show --resource-group shopping-app-rg --name shopping-app-cosmos --query documentEndpoint --output tsv
  • Copy the URL that is printed (it looks like https://shopping-app-cosmos.documents.azure.com:443/).
  • Retrieve your Cosmos DB primary key by running:
az cosmosdb keys list --resource-group shopping-app-rg --name shopping-app-cosmos --query primaryMasterKey --output tsv
  • Copy the long Base64 string that is printed. This is your account key.

Why not store these in a file?

These credentials give full access to your database. You will set them as environment variables in your terminal session so they never end up in your code or get committed to Git.

  • Set the environment variables in your terminal by running these commands (paste your own values):

macOS

export COSMOS_ENDPOINT=your-endpoint-here
export COSMOS_KEY=your-key-here

Windows

$env:COSMOS_ENDPOINT="your-endpoint-here"
$env:COSMOS_KEY="your-key-here"
  • Replace your-endpoint-here with the URL you copied earlier.
  • Replace your-key-here with the Base64 key you copied.

Getting an authentication error later?

Make sure you set the environment variables in the same terminal session where you run the app. If you open a new terminal window, you need to set them again.

Double-check that you copied the full key with no extra spaces or line breaks.

Help me debug my Cosmos DB connection.

Test the Cosmos DB connection locally

Your server.js already has the logic to connect to Cosmos DB when the environment variables are present. The initializeDatabase() function creates the database and container if they do not exist, then switches all CRUD operations to use Cosmos DB instead of the in-memory array.

  • Start your server by running:
npm start
  • You should see Connected to Cosmos DB followed by Server running on port 3000 in the terminal output.
  • Open http://localhost:3000 in your browser.
  • Add a test product using the form (for example, name: Test Widget, price: 9.99).
  • Stop the server with Ctrl+C, then restart it with npm start.
  • Refresh http://localhost:3000 in your browser. Your product is still there because it is now stored in Cosmos DB.

What is happening behind the scenes?

When COSMOS_ENDPOINT and COSMOS_KEY are set, initializeDatabase() uses the @azure/cosmos SDK to create a database called shoppingdb and a container called products with partition key /id. All your API routes then read and write from Cosmos DB instead of the in-memory array.

If the environment variables are missing, the app falls back to in-memory storage. This dual-mode design lets you develop without a database connection when needed.

Don't see Connected to Cosmos DB?

Confirm your environment variables are set in the same terminal by running echo $COSMOS_ENDPOINT (macOS) or echo $env:COSMOS_ENDPOINT (Windows). If the output is blank, set them again.

If you see a network error, check that the Cosmos DB account finished provisioning by running az cosmosdb show -n shopping-app-cosmos -g shopping-app-rg --query provisioningState.

Help me connect to Cosmos DB from my local app.

✔️ Awesome, I've got everything!

Great. Make sure you have saved server.js and confirmed data persists across server restarts.

ⓧ I'd like to double check the full code

Here is the complete server.js for comparison:

const express = require('express');
const path = require('path');
const { CosmosClient } = require('@azure/cosmos');

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

app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

// Cosmos DB configuration
const endpoint = process.env.COSMOS_ENDPOINT;
const key = process.env.COSMOS_KEY;
const databaseId = 'shoppingdb';
const containerId = 'products';

let container;

async function initializeDatabase() {
  if (!endpoint || !key) {
    console.log('Cosmos DB not configured. Using in-memory storage.');
    return;
  }
  const client = new CosmosClient({ endpoint, key });
  const { database } = await client.databases.createIfNotExists({ id: databaseId });
  const { container: cosmosContainer } = await database.containers.createIfNotExists({
    id: containerId,
    partitionKey: { paths: ['/id'] }
  });
  container = cosmosContainer;
  console.log('Connected to Cosmos DB');
}

// In-memory fallback for local development
let products = [];
let nextId = 1;

// GET all products
app.get('/api/products', async (req, res) => {
  try {
    if (container) {
      const { resources } = await container.items.readAll().fetchAll();
      res.json(resources);
    } else {
      res.json(products);
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// POST create a product
app.post('/api/products', async (req, res) => {
  try {
    const { name, price, description } = req.body;
    if (!name || !price) {
      return res.status(400).json({ error: 'Name and price are required' });
    }
    const product = {
      id: Date.now().toString(),
      name,
      price: parseFloat(price),
      description: description || ''
    };
    if (container) {
      const { resource } = await container.items.create(product);
      res.status(201).json(resource);
    } else {
      products.push(product);
      res.status(201).json(product);
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// PUT update a product
app.put('/api/products/:id', async (req, res) => {
  try {
    const { id } = req.params;
    const { name, price, description } = req.body;
    if (container) {
      const { resource } = await container.item(id, id).read();
      if (!resource) {
        return res.status(404).json({ error: 'Product not found' });
      }
      const updated = { ...resource, name, price: parseFloat(price), description };
      const { resource: result } = await container.item(id, id).replace(updated);
      res.json(result);
    } else {
      const index = products.findIndex(p => p.id === id);
      if (index === -1) {
        return res.status(404).json({ error: 'Product not found' });
      }
      products[index] = { ...products[index], name, price: parseFloat(price), description };
      res.json(products[index]);
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// DELETE a product
app.delete('/api/products/:id', async (req, res) => {
  try {
    const { id } = req.params;
    if (container) {
      await container.item(id, id).delete();
      res.status(204).send();
    } else {
      products = products.filter(p => p.id !== id);
      res.status(204).send();
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Initialize and start
initializeDatabase().then(() => {
  app.listen(port, () => {
    console.log(`Server running on port ${port}`);
  });
});

Your app now has persistent storage in the cloud. Next up, you will deploy the entire application to Azure App Service so anyone can access it from a public URL.

Deploy to Azure App Service

Your shopping app works locally, but it is only accessible on your own machine. To make it available on the internet, you need a cloud hosting service that can run your Node.js server and serve requests from anywhere.

In this step, you will deploy your application to Azure App Service, Microsoft's managed hosting platform for web apps. You will also connect it to your Cosmos DB database by configuring environment variables in the cloud.

In this step, get ready to:
  • Create an Azure App Service on the F1 Free tier using Azure CLI.
  • Configure your Cosmos DB connection as environment variables in the cloud.
  • Verify your live application works at its public Azure URL.
Create an Azure App Service with az webapp up

The az webapp up command is a powerful shortcut that creates an App Service plan, creates the web app, and deploys your code all in a single command. It detects that your project is a Node.js app and configures the runtime automatically.

  • Make sure your terminal is in the azure-shopping-app project folder (where package.json lives).
  • Deploy your app to Azure App Service on the F1 Free tier by running this command:
az webapp up --name [[AZURE_APP_NAME="a-unique-app-name"]] --resource-group shopping-app-rg --sku F1

What does this command do?

  • --name sets a globally unique name for your web app. Valid characters are a-z, 0-9, and -. A good pattern is your name plus a project identifier, like yourname-shopping-app.
  • --resource-group shopping-app-rg places the App Service in your existing resource group alongside Cosmos DB.
  • --sku F1 creates the web app on the Free pricing tier, which gives you 60 CPU minutes per day and 1 GB of storage at no cost.

The command creates a Linux App Service plan, creates the web app, and performs a ZIP deployment of all files in your current directory.

  • Wait for the command to finish. It takes a few minutes to complete.
  • When it finishes, look for the JSON output. Note the URL field. This is your app's public address.

What should I see?

The command outputs JSON that includes your app's URL, resource group, SKU, and runtime version. The URL follows the pattern https://<your-app-name>.azurewebsites.net.

Seeing an error about the app name?

The app name must be globally unique across all of Azure. If your chosen name is taken, try adding numbers or your initials.

If you see an error about the runtime stack not being detected, make sure you are running the command from inside your project folder where package.json exists.

Help me troubleshoot my az webapp up deployment

Configure environment variables for Cosmos DB

Your app is deployed, but it cannot connect to Cosmos DB yet. Locally, you used environment variables for the database credentials. In Azure App Service, these are called application settings. They are injected as environment variables at runtime.

  • Set the COSMOS_ENDPOINT and COSMOS_KEY application settings by running this command:
az webapp config appsettings set --name [[AZURE_APP_NAME="a-unique-app-name"]] --resource-group shopping-app-rg --settings COSMOS_ENDPOINT=[[COSMOS_ENDPOINT="your Cosmos DB endpoint URL"]] COSMOS_KEY=your-cosmos-key-here

What does this command do?

  • az webapp config appsettings set adds or updates application settings on your App Service.
  • --settings accepts space-separated KEY=VALUE pairs. These become environment variables that your Node.js app reads via process.env.
  • Your server.js already reads process.env.COSMOS_ENDPOINT and process.env.COSMOS_KEY, so once these settings are configured, the app connects to Cosmos DB automatically.

Setting these values triggers an app restart, which applies the new settings immediately.

Where to find your Cosmos DB credentials

Use the same endpoint URL and key you used for local testing in the previous step. If you need to retrieve them again, run az cosmosdb keys list with your account name and resource group.

Settings not saving?

Make sure the --name value matches the exact app name you used with az webapp up. The name is case-sensitive.

If your Cosmos DB endpoint or key contains special characters, wrap the entire value in double quotes.

Help me troubleshoot my Azure App Service application settings

Verify the live deployment

Your app is now deployed and connected to Cosmos DB. Time to confirm everything works at the live URL.

  • Open your browser and navigate to your app's URL. It follows the pattern https://<your-app-name>.azurewebsites.net.
  • You should see your Shopping Catalog page with the product form and an empty product list.
  • Add a test product by filling in the form and clicking Add Product.
  • Refresh the page. The product should still be there because it is now stored in Cosmos DB.

Seeing an application error page?

If your app shows a generic error, check that your environment variables are set correctly. Verify your settings are in place by running:

az webapp config appsettings list --name [[AZURE_APP_NAME="a-unique-app-name"]] --resource-group shopping-app-rg

Confirm that both COSMOS_ENDPOINT and COSMOS_KEY appear in the output. If they are missing, re-run the az webapp config appsettings set command from the previous substep.

Help me debug my Azure App Service deployment

Your app is now live on the internet. Next up, you will set up a CI/CD pipeline so that every code change you push is automatically deployed to this App Service.

Set Up GitHub Actions CI/CD Pipeline

Your shopping app is live on Azure, but right now deploying changes means running manual CLI commands every time. Professional teams automate this process so that every code push triggers a deployment automatically.

In this step, you will push your project to GitHub and create a GitHub Actions workflow that builds and deploys your app to Azure on every push to the main branch.

In this step, get ready to:
  • Push your project to a new GitHub repository.
  • Download the Azure publish profile and store it as a GitHub secret.
  • Create a GitHub Actions workflow that auto-deploys on push to main.
Push the project to a GitHub repository

Your code needs to live on GitHub so that GitHub Actions can detect pushes and trigger deployments.

  • Initialize a Git repository in your project folder by running these commands:
git init
git add .
git commit -m "Initial commit"

What do these commands do?

The first command creates a new Git repository in your current folder. The second stages all your project files for tracking. The third saves a snapshot of your code as the first commit.

  • Create a new GitHub repository and push your code by running this command:
gh repo create azure-shopping-app --source=. --public --push

What does this command do?

This uses the GitHub CLI to create a new public repository called azure-shopping-app on your GitHub account. The --source=. flag tells it to use your current directory, and --push pushes your commit to GitHub immediately.

What if I don't have the GitHub CLI installed?

If you see a "command not found" error for gh, you can install it from cli.github.com. After installing, run gh auth login and follow the prompts to authenticate with your GitHub account.

  • Verify the push succeeded by visiting your GitHub profile in a browser.
  • You should see a new repository called azure-shopping-app containing all your project files.

Don't see your repository on GitHub?

Make sure you authenticated with the GitHub CLI by running gh auth login first.

If the push failed with a permission error, check that your GitHub account has the ability to create repositories.

help me push my project to GitHub

Download the Azure publish profile and add it as a GitHub secret

GitHub Actions needs credentials to deploy code to your Azure App Service. The publish profile is an XML file that contains the authentication details GitHub Actions will use.

  • Download your App Service publish profile by running this command:
az webapp deployment list-publishing-profiles --name [[AZURE_APP_NAME="your-app-name"]] --resource-group shopping-app-rg --xml

What does this command do?

This retrieves the publishing profile for your App Service in XML format. The output contains credentials that allow GitHub Actions to deploy directly to your app without needing your Azure login.

  • Copy the entire XML output from the terminal (everything from <publishData> to </publishData>).
  • Open your GitHub repository in a browser.
  • Click **Settings** at the top of the repository page.
  • In the left sidebar, click **Secrets and variables**, then click **Actions**.
  • Click **New repository secret**.
  • Enter AZURE_WEBAPP_PUBLISH_PROFILE in the **Name** field.
  • Paste the entire XML output into the **Secret** field.
  • Click **Add secret**.
  • Add a second secret for your app name by clicking **New repository secret** again.
  • Enter AZURE_WEBAPP_NAME in the **Name** field.
  • Enter your App Service name (the same name you used in the az webapp up command earlier) in the **Secret** field.
  • Click **Add secret**.
  • Verify both secrets appear in the **Repository secrets** list. You should see AZURE_WEBAPP_NAME and AZURE_WEBAPP_PUBLISH_PROFILE listed.

Why use GitHub secrets?

GitHub secrets encrypt sensitive values so they never appear in logs or code. Your workflow references them with the ${{ secrets.SECRET_NAME }} syntax, and GitHub injects the real value at runtime.

Can't find the Settings tab?

Make sure you are the owner of the repository. Only repository owners and admins can access the **Settings** tab and manage secrets.

help me add secrets to my GitHub repository

Create the GitHub Actions workflow file

A CI/CD pipeline needs a configuration file that tells it what to do on each trigger. GitHub Actions uses YAML workflow files stored in a .github/workflows/ directory.

  • Create the workflow directory by running these commands:
mkdir -p .github/workflows
  • Create the workflow file .github/workflows/deploy.yml and add the following content:
name: Deploy to Azure App Service

on:
  push:
    branches:
      - main

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install dependencies
        run: npm install

      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ secrets.AZURE_WEBAPP_NAME }}
          publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
          package: .

What does this workflow do?

  • on: push: branches: - main triggers the workflow every time you push to the main branch.
  • actions/checkout@v4 pulls your latest code into the workflow runner.
  • actions/setup-node@v4 installs Node.js 22 and caches npm packages for faster builds.
  • npm install installs your project dependencies on the workflow runner.
  • azure/webapps-deploy@v3 deploys the entire project directory to your Azure App Service using the publish profile credentials.
  • Save the file.
  • Push the workflow file to GitHub by running these commands:
git add .github/workflows/deploy.yml
git commit -m "Add GitHub Actions deploy workflow"
git push
  • Open your GitHub repository in a browser and click the **Actions** tab at the top.
  • You should see a workflow run named "Add GitHub Actions deploy workflow" either in progress or queued.

Workflow not appearing in the Actions tab?

Double check that your file is at exactly .github/workflows/deploy.yml in the repository. The directory name must be .github with a period prefix.

Make sure you committed and pushed the file. Run git status to confirm there are no uncommitted changes.

help me get my GitHub Actions workflow to trigger

✔️ Awesome, I've got everything!

Great. Double check you have saved .github/workflows/deploy.yml and pushed it to GitHub.

ⓧ I'd like to double check the full code

Here is the complete workflow file:

name: Deploy to Azure App Service

on:
  push:
    branches:
      - main

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install dependencies
        run: npm install

      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ secrets.AZURE_WEBAPP_NAME }}
          publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
          package: .

Your CI/CD pipeline is wired up. Next up, you will make a code change and watch the full pipeline deploy it automatically to your live site.

Test the Full CI/CD Pipeline

Your CI/CD pipeline is wired up and ready to go. Now it is time to prove it actually works end-to-end.

The real test of any deployment pipeline is simple: make a change, push it, and see it appear live without any manual intervention. In this step, you will make a visible code change, push it to GitHub, and watch GitHub Actions automatically deploy it to your Azure App Service.

In this step, get ready to:
  • Make a visible code change to your application.
  • Push the change and watch GitHub Actions deploy it automatically.
  • Verify the updated change appears live on your Azure URL.
Make a visible code change

To test the pipeline, you need a change that is easy to spot on the live site. Updating the page heading is the quickest way to confirm your deployment worked.

  • In your editor, open public/index.html.
  • Find this line:
<h1>Shopping Catalog</h1>
  • Replace Shopping Catalog with a new heading of your choice, so the line looks something like this:
<h1>My Shopping Catalog</h1>

Why this specific change?

The <h1> heading is the first thing you see when the page loads. By changing it to something unique, you can instantly confirm the new code reached Azure without inspecting network requests or logs.

  • Save public/index.html.
Push the change to GitHub

With your change saved, you need to commit it and push to the main branch. This is the trigger that kicks off your GitHub Actions workflow.

  • Stage, commit, and push your change by running these commands:
git add .
git commit -m "Update page heading"
git push

What do these commands do?

  • git add . stages all changed files for the next commit.
  • git commit -m saves the staged changes as a snapshot with a descriptive message.
  • git push uploads your commit to the main branch on GitHub. Because your workflow triggers on pushes to main, this automatically starts the deployment.
  • Open your GitHub repository in your browser.
  • Click the **Actions** tab at the top of your repository page.
  • You should see a new workflow run titled Update page heading (matching your commit message) with a yellow spinning icon indicating it is in progress.
  • Click on the workflow run name to watch the steps execute in real time.

The workflow will run through checkout, Node.js setup, dependency installation, and deployment. This typically takes 1-2 minutes.

  • Wait until the workflow run shows a green checkmark. This means all steps completed successfully.

Workflow failed with a red X?

  • Click into the failed job and expand the step that failed. The error message will tell you what went wrong.
  • If the **Deploy to Azure Web App** step failed, check that your AZURE_WEBAPP_PUBLISH_PROFILE secret contains the full XML content from your publish profile file.
  • If the **Install dependencies** step failed, confirm that your package.json is committed and pushed to the repository.

My GitHub Actions workflow is failing and I need help debugging the error

Verify the change is live on Azure

Once the workflow shows a green checkmark, your updated code is live on Azure. Time to confirm it with your own eyes.

  • Open your Azure App Service URL in your browser:
https://[[AZURE_APP_NAME="your-app-name"]].azurewebsites.net
  • Confirm that the page heading now displays your updated text instead of the original Shopping Catalog.

What just happened?

You changed one line of code, pushed it, and the update appeared on a live public URL without you touching Azure at all. That is the power of CI/CD.

Every future push to main will trigger the same automated flow: checkout, install, deploy. This is exactly how professional engineering teams ship code to production.

Still seeing the old heading?

  • Hard-refresh the page with **Cmd+Shift+R** (macOS) or **Ctrl+Shift+R** (Windows) to bypass your browser cache.
  • Check the GitHub Actions tab to confirm the workflow completed with a green checkmark. If it is still running, wait for it to finish.
  • Azure App Service can take 30-60 seconds to restart after a deployment. Wait a moment and refresh again.

Help me figure out why my code change is not showing on my Azure App Service URL after a successful GitHub Actions deployment

Secret mission

Add Search and Category Filtering

Your shopping catalog works, but real catalogs let users find what they need quickly. In this mission, you will add product search by name and category filtering to both the backend and frontend, then deploy the changes through your CI/CD pipeline.

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 Azure services in this project are on free tiers, so there are no ongoing costs as long as you stay within the free limits.

Resources you used:

  • Azure Cosmos DB account (free tier: 1000 RU/s, 25 GB storage, lifetime free per subscription).
  • Azure App Service (F1 Free tier: 60 CPU minutes/day, 1 GB storage).
  • GitHub repository with Actions workflow and configured secrets.
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to revisit the project later.

  • Both Azure services are on free tiers. No charges will accrue as long as you stay within the free limits.
  • Your GitHub Actions workflow will continue to deploy on every push to main.
  • Your local project files remain on your Desktop for continued development.

Pause - I'll come back to this later

Free up local resources while keeping your cloud services intact. Since both Azure services are on free tiers, there is nothing to pause on the cloud side.

  • Stop your local Node.js server if it is still running by pressing Ctrl+C in the terminal.
  • Your Azure App Service and Cosmos DB will remain available at no cost.
  • Your GitHub repository and workflow will remain intact for when you return.

Delete - I don't want to use this again

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

  • Delete all Azure resources (App Service and Cosmos DB) by running this command in your terminal:
  • This deletes the entire shopping-app-rg resource group, which contains your App Service and Cosmos DB account.
  • Delete your GitHub repository by navigating to your repository on GitHub.
  • Click Settings in the repository menu.
  • Scroll down to the Danger Zone section at the bottom.
  • Click Delete this repository and follow the confirmation prompts.
  • Delete your local project files by running:
  • On Windows, run this instead:

That's a Wrap!

That's a Wrap!

Nice work. You just shipped a full-stack application to the cloud with fully automated deployments.

You've learned how to:

  • Built a Node.js/Express REST API with full CRUD operations and connected it to Azure Cosmos DB for cloud data persistence.
  • Deployed to Azure App Service using the Azure CLI and configured environment variables for production.
  • Configured a GitHub Actions CI/CD pipeline that automatically builds and deploys your app on every push to main.
  • Added search and category filtering to the shopping catalog and deployed the changes through your pipeline.

Ready to quiz yourself?