Build a CI/CD Pipeline for Node.js

Automate testing and deployment of a Node.js API with GitHub Actions.

Introduction

30 Second Summary

Every developer has pushed code and crossed their fingers, hoping nothing breaks. Without automation, deploying is a manual process where bugs slip through and only get caught after real users hit them.

In this project, you will build a Node.js task tracker API and connect it to a CI/CD pipeline that tests and deploys your code automatically. Push to GitHub, and your pipeline handles the rest.

What You'll Build

When you finish, you'll push a code change and watch your GitHub Actions pipeline run tests, catch a bug, and deploy your live API to a public URL anyone can visit.

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

  • An automated test suite that runs in the cloud every time you push code to GitHub, catching bugs before they reach production.
  • A pipeline failure that catches a deliberate bug you introduced, proving the safety net actually works.
  • A live task tracker API deployed on Render at a public URL that updates automatically when tests pass.
  • Secret Mission: Add a linting step to your pipeline and configure branch protection rules that block bad code from reaching main.

Are there any prerequisites?

This project assumes basic JavaScript and Node.js familiarity, a GitHub account, and comfort with Git basics like committing and pushing. No CI/CD experience is needed.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters. You're creating a task tracker API with automated testing and deployment, so bugs get caught before they ever reach production.

Set Up the Project and Express API

Before you can automate testing or deployment, you need something to test and deploy. A working API running on your machine is the foundation everything else builds on.

In this step, you'll create a Node.js project with Express and build a task tracker API with three endpoints. Once it runs locally and responds with JSON, you'll have a solid base for the CI/CD pipeline.

In this step, get ready to:
  • Create a project folder and initialize it with npm.
  • Write an Express API with three endpoints for managing tasks.
  • Run the server locally and confirm it responds with JSON.
Create the project directory and install dependencies
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-tracker-cicd
cd task-tracker-cicd
  • Confirm the folder exists by running:
ls

You should see an empty directory with no output.

  • Initialize a new Node.js project by running:
npm init -y

You should see npm print out a default package.json with your project name, version, and other fields.

What does this command do?

The npm init -y command creates a package.json file with sensible defaults. This file tracks your project's name, version, dependencies, and scripts.

  • Open the project in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the task-tracker-cicd folder, then click Open.

You should see package.json listed in the Explorer sidebar on the left.

  • Open package.json by clicking on it in the Explorer sidebar.
  • Select all the content and replace it with the following:
{
  "name": "task-tracker-cicd",
  "version": "1.0.0",
  "description": "A task tracker API with CI/CD pipeline",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}

What does this file do?

This package.json defines your project's metadata and dependencies. The scripts.start field tells Node.js how to start your server.

The dependencies section lists Express 5.2.1 as the web framework your API uses. Running npm install reads this list and downloads everything your project needs.

  • Save package.json.
  • Install the project dependencies by running this command in your terminal:
npm install

You should see output confirming that Express was installed and a node_modules folder appeared in the Explorer sidebar.

Seeing an error from npm install?

  • Make sure your terminal is inside the task-tracker-cicd folder. Run pwd to check.
  • Check that package.json is valid JSON with no trailing commas or typos.

Help me fix my npm install error

Build the task tracker API

Your project needs two files. The first, app.js, defines the Express application and its endpoints. The second, index.js, starts the server.

  • Create a new file called app.js by clicking the new file icon in the Explorer sidebar.
  • Add the following code to set up Express and your first endpoint:
const express = require("express");
const app = express();

// Parse incoming JSON request bodies
app.use(express.json());

// In-memory task store with sample data
let tasks = [
  { id: 1, title: "Learn CI/CD", completed: false },
  { id: 2, title: "Set up GitHub Actions", completed: false },
];

let nextId = 3;

// Return all tasks
app.get("/tasks", (req, res) => {
  res.json(tasks);
});

module.exports = app;

What does this code do?

  • It imports Express and creates an app instance.
  • The express.json() middleware lets the app parse JSON bodies from incoming requests.
  • The tasks array acts as a simple in-memory database to store task objects.
  • The GET /tasks endpoint returns the full list of tasks as JSON.
  • The module.exports line makes the app importable by other files (like your server starter).
  • Save app.js.
  • Create another new file called index.js and add the following code:
const app = require("./app");

// Use PORT from environment or default to 3000
const PORT = process.env.PORT || 3000;

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

What does this code do?

  • It imports the Express app from app.js.
  • It reads a PORT environment variable (useful for deployment later) or defaults to port 3000.
  • The app.listen call starts the server and logs a confirmation message.
  • Save index.js.
  • Start the server by running this command in your terminal:
node index.js

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

  • Open your browser and visit http://localhost:3000/tasks.

You should see a JSON array with two tasks.

Not seeing the JSON response?

  • Check that your terminal still shows Server running on port 3000. If you see an error, read the message for a file name and line number to investigate.
  • Make sure the browser URL is exactly http://localhost:3000/tasks with no trailing slash or typos.
  • If port 3000 is already in use, stop any other servers and try again.

Help me fix my Express server

Your GET endpoint works. Now add the remaining two endpoints so users can create and complete tasks.

  • Stop the server by pressing Ctrl+C in the terminal.
  • In app.js, find the line that says module.exports = app; and add the following code above it:
// Create a new task
app.post("/tasks", (req, res) => {
  const { title } = req.body;
  if (!title) {
    return res.status(400).json({ error: "Title is required" });
  }
  const task = { id: nextId++, title, completed: false };
  tasks.push(task);
  res.status(201).json(task);
});

What does this endpoint do?

The POST /tasks endpoint reads a title from the request body. If no title is provided, it returns a 400 error. Otherwise, it creates a new task object, adds it to the array, and responds with a 201 status.

  • Below the POST endpoint you just added (still above module.exports = app;), add the final endpoint:
// Mark a task as completed
app.patch("/tasks/:id/complete", (req, res) => {
  const id = parseInt(req.params.id);
  const task = tasks.find((t) => t.id === id);
  if (!task) {
    return res.status(404).json({ error: "Task not found" });
  }
  task.completed = true;
  res.json(task);
});

What does this endpoint do?

The PATCH /tasks/:id/complete endpoint finds a task by its ID in the URL. If the task does not exist, it returns a 404. Otherwise, it sets completed to true and returns the updated task.

  • Save app.js.

✔️ Awesome, I've got everything!

Great. Double check you've saved app.js and that module.exports = app; is still the very last line in the file.

ⓧ I'd like to double check the full code

Scroll through your app.js and confirm these sections appear in order:

  • The Express require, app creation, and express.json() middleware at the top.
  • The tasks array and nextId variable.
  • The GET /tasks route.
  • The POST /tasks route.
  • The PATCH /tasks/:id/complete route.
  • The module.exports = app; line at the very bottom.

And confirm your index.js looks like this:

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

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

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
Run the server and verify all three endpoints

Before you run the server, think about what you expect to see when you visit /tasks in your browser. Do you think the response will look different from last time?

  • Start the server again by running:
node index.js
  • Open your browser and visit http://localhost:3000/tasks.

You should see the same JSON array of two tasks as before. The response looks the same because you haven't added any new tasks yet.

What about testing POST and PATCH?

You can only test GET endpoints directly in the browser address bar. POST and PATCH require a tool like curl or a REST client. In the next step, you'll write automated tests that exercise all three endpoints for you.

Seeing a syntax error when starting the server?

  • Read the error message for a line number. Open app.js at that line and look for missing commas, unclosed brackets, or typos.
  • Make sure module.exports = app; appears only once and is the last line in app.js.

Help me fix my syntax error in app.js

Your API is running locally and responding with task data. Next up, you'll write automated tests that verify all three endpoints work correctly, so the pipeline has something to check on every push.

Add Tests with Jest

Your API works locally. You can start the server and hit each endpoint. But right now, the only way to check if the code still works after a change is to manually test every route yourself.

In this step, you'll add automated tests that verify your API's behaviour in seconds. These tests are what your CI pipeline will run later to catch bugs before they reach production.

In this step, get ready to:
  • Install Jest and supertest as testing libraries.
  • Add a test script to your project.
  • Write three tests covering each API endpoint.
Install Jest and supertest

Your tests need two packages. Jest is the testing framework that discovers and runs your test files. supertest is a library that makes HTTP requests to your Express app without starting a real server.

  • Install both packages as dev dependencies by running this command:
npm install --save-dev jest@30.4.2 supertest@7.1.0

What are dev dependencies?

Dev dependencies are packages needed only during development and testing. They don't ship with your production code. The --save-dev flag tells npm to list them separately in package.json under devDependencies.

Now you need to tell npm how to run your tests. The test script in package.json defines what command runs when you type npm test.

  • Open package.json and find the scripts section:
"scripts": {
  "start": "node index.js"
}
  • Add a comma after the start line and add a new test script, so the section looks like this:
"scripts": {
  "start": "node index.js",
  "test": "jest"
}
  • Save package.json.

Before you run Jest, what do you think will happen if there are no test files in the project yet?

  • Confirm Jest is installed correctly by running:
npx jest

You'll see a message saying no tests were found, and Jest exits with code 1. That's expected. Jest is installed and working, it just has nothing to run yet.

Seeing 'command not found'?

Make sure you ran the install command in the same task-tracker-cicd folder where your package.json lives. Check that node_modules/.bin/jest exists.

help me fix Jest not being found after install

Write your test file

Why can tests import your app without starting the server?

In Step 1, you split the project into two files. app.js defines the Express app and exports it. index.js imports the app and starts listening on a port.

This separation means your tests can import the app directly from app.js and use supertest to send requests to it in memory. No real server starts, no port conflicts, and tests run fast.

  • Create a new file called app.test.js in your task-tracker-cicd folder by running:
touch app.test.js
  • Confirm the file exists by running:
ls

You should see app.test.js listed alongside app.js, index.js, and package.json.

  • Open app.test.js in VS Code and add the imports and first test:
const request = require("supertest");
const app = require("./app");

describe("Task API", () => {
  test("GET /tasks returns a list of tasks", async () => {
    const response = await request(app).get("/tasks");
    expect(response.status).toBe(200);
    expect(Array.isArray(response.body)).toBe(true);
    expect(response.body.length).toBeGreaterThan(0);
  });
});

What does this test do?

  • request(app) creates a test client that sends requests to your Express app in memory.
  • .get("/tasks") sends a GET request to the /tasks endpoint.
  • The three expect lines check that the response has status 200, returns an array, and the array is not empty.
  • Save app.test.js and run your first test:
npx jest

You should see 1 test passing with green output. One down, two to go.

  • Return to app.test.js and add two more tests inside the describe block, below your GET test and before the closing });:
  test("POST /tasks creates a new task", async () => {
    const response = await request(app)
      .post("/tasks")
      .send({ title: "Test task" });
    expect(response.status).toBe(201);
    expect(response.body.title).toBe("Test task");
    expect(response.body.completed).toBe(false);
  });

  test("PATCH /tasks/:id/complete marks a task as completed", async () => {
    const response = await request(app).patch("/tasks/1/complete");
    expect(response.status).toBe(200);
    expect(response.body.completed).toBe(true);
  });

What do these tests check?

  • The POST test sends a JSON body with a title and verifies the response includes the new task with completed: false.
  • The PATCH test marks task 1 as complete and verifies completed flips to true.
  • Save app.test.js.

Before you run the full suite, how many tests do you expect to pass?

  • Run the full test suite:
npx jest

All 3 tests pass with green output. Your API's core behaviour is now verified automatically.

Tests failing?

Check that your app.test.js file is in the same folder as app.js. Jest looks for files matching *.test.js in your project directory.

Make sure the closing }); for the describe block is present at the end of the file. A missing brace causes a syntax error.

help me debug my failing Jest tests

✔️ Awesome, I've got everything!

Great. Double check you've saved app.test.js and confirm all 3 tests pass when you run npx jest.

ⓧ I'd like to double check the full code

Here is the complete app.test.js file:

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

describe("Task API", () => {
  test("GET /tasks returns a list of tasks", async () => {
    const response = await request(app).get("/tasks");
    expect(response.status).toBe(200);
    expect(Array.isArray(response.body)).toBe(true);
    expect(response.body.length).toBeGreaterThan(0);
  });

  test("POST /tasks creates a new task", async () => {
    const response = await request(app)
      .post("/tasks")
      .send({ title: "Test task" });
    expect(response.status).toBe(201);
    expect(response.body.title).toBe("Test task");
    expect(response.body.completed).toBe(false);
  });

  test("PATCH /tasks/:id/complete marks a task as completed", async () => {
    const response = await request(app).patch("/tasks/1/complete");
    expect(response.status).toBe(200);
    expect(response.body.completed).toBe(true);
  });
});

And confirm your package.json scripts section looks like this:

"scripts": {
  "start": "node index.js",
  "test": "jest"
}

Your API now has a safety net. Any time you change the code, you can run these tests and know in seconds whether something broke. Next up, you'll move these tests into the cloud so they run automatically on every push.

Create the GitHub Actions Workflow

Your tests pass locally, which is great. But what happens when you forget to run them before pushing? Or when a teammate pushes without testing?

You need tests to run automatically on every push so bugs never sneak past. This is the core of Continuous Integration. In this step, you'll write a GitHub Actions workflow that runs your test suite in the cloud every time code reaches GitHub.

In this step, get ready to:
  • Create a GitHub Actions workflow file that triggers on every push.
  • Define a CI job that installs dependencies and runs tests.
  • Push your project to GitHub and watch the pipeline run.
Create the workflow file with triggers

GitHub Actions looks for workflow files inside a special .github/workflows directory in your repository. Each YAML file in that folder defines a pipeline.

  • Create the workflow directory by running this command:
mkdir -p .github/workflows
  • Confirm the directory was created by running:
ls .github

You should see workflows listed in the output.

  • Create a new file called ci.yml inside .github/workflows and paste the following code:
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

What does this configuration do?

  • name: CI/CD Pipeline gives the workflow a human-readable label that appears in the GitHub Actions tab.
  • on: push triggers the workflow every time you push code to the main branch.
  • on: pull_request also triggers it when someone opens a pull request targeting main.
Define the test job

The trigger tells GitHub WHEN to run. Now you need to define WHAT it runs. A job is a sequence of steps that execute on a fresh virtual machine.

  • In .github/workflows/ci.yml, add the following below what you already have:
jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "22"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

What does each step do?

  • runs-on: ubuntu-latest tells GitHub to spin up a fresh Linux machine for this job.
  • actions/checkout@v7 clones your repository code onto that machine.
  • actions/setup-node@v7 installs Node.js 22 and caches your npm packages for faster future runs.
  • npm ci installs dependencies from your lockfile. It is stricter than npm install because it fails if the lockfile and package.json disagree.
  • npm test runs your Jest test suite, just like it does locally.
  • Save .github/workflows/ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you saved the file before continuing.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/ci.yml file:

name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "22"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Not sure about the YAML indentation?

YAML is sensitive to spaces. Every nested level uses exactly 2 spaces (not tabs). If you see errors later about invalid workflow files, check that your indentation matches the code above exactly.

help me fix my ci.yml indentation

Push to GitHub and watch the pipeline run

Your workflow file is ready, but GitHub Actions only triggers when code is on GitHub. You need to initialize a Git repository, create a remote repo on GitHub, and push everything up.

  • Create a .gitignore file to keep node_modules out of your repository by running:
echo "node_modules" > .gitignore
  • Initialize a Git repository and make your first commit by running these commands:
git init
git add .
git commit -m "Initial commit with task API and tests"

You should see a summary of files committed, including app.js, app.test.js, index.js, package.json, package-lock.json, and your .github/workflows/ci.yml file.

Seeing 'Author identity unknown'?

Git needs your name and email for commits. Run these two commands with your own details, then retry the commit:

git config --global user.email "you@example.com"
git config --global user.name "Your Name"

help me configure my Git identity

Now you need a repository on GitHub to push to. You will create one through the GitHub website.

  • Open https://github.com/new in your browser.
  • Enter task-tracker-cicd in the **Repository name** field.
  • Select **Public** for the visibility.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click **Create repository**.

Why public?

GitHub Actions is free and unlimited for public repositories. A private repo has a monthly minutes cap on the free plan. Since this is a learning project, public keeps things simple and free.

GitHub shows you the commands to push an existing repository. You will use those now.

  • Connect your local repo to GitHub and push by running these commands (replace the URL with yours from the GitHub page):
git remote add origin [[REPO_URL="https://github.com/YOUR-USERNAME/task-tracker-cicd.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin links your local repo to the GitHub repository you just created.
  • git branch -M main renames your default branch to main (some systems default to master).
  • git push -u origin main uploads all your files and commits to GitHub.

Authentication error when pushing?

GitHub no longer accepts passwords for Git operations. If prompted for credentials, you need a personal access token or SSH key. GitHub will show you instructions for authenticating when the push fails.

help me authenticate Git with GitHub

The moment your code lands on GitHub, the workflow triggers automatically because you pushed to main.

  • In your browser, navigate to your repository on GitHub.
  • Click the **Actions** tab at the top of the page.

Before you look at the result, what do you expect to see? Do you think the workflow will pass or fail?

  • Look for a workflow run titled with your commit message. Click on it to see the details.

You should see a green checkmark next to the workflow run. Click into the **test** job to expand the logs and you will see your three Jest tests passing with green output.

Seeing a red X instead of a green checkmark?

Click into the failed job and read the error message in the logs. Common causes:

  • Missing package-lock.json in the repo. The npm ci command requires it. Run npm install locally to generate it, then commit and push again.
  • YAML indentation errors. Check that every level uses exactly 2 spaces.
  • Wrong file path. Your workflow file must be at .github/workflows/ci.yml exactly.

help me debug my failing GitHub Actions workflow

Your pipeline is live. Every push to main now runs your tests automatically in the cloud. Next up, you will see what happens when those tests catch a bug.

Break the Build (and Watch CI Catch It)

Your pipeline ran and went green. That feels good. But a pipeline that always passes teaches you nothing about whether it actually protects your code.

In this step, you will deliberately break the API and push the broken code to GitHub. The goal is to see the pipeline catch the bug with a red X before it ever reaches production.

In this step, get ready to:
  • Introduce a deliberate bug in the API that changes the response shape.
  • Push the broken code and watch the pipeline fail.
  • Read the error message in the Actions logs to understand what went wrong.
Introduce a deliberate bug in the API

Your test for GET /tasks expects the response body to be a plain array. If you wrap that array inside an object, the test will fail because the response shape no longer matches what the test expects.

  • In VS Code, open app.js and scroll down to the GET /tasks route handler.
  • Find this line:
res.json(tasks);
  • Replace it with this line, which wraps the array inside an object:
res.json({ data: tasks });

What does this change do?

The original route returned a plain JSON array like [{...}, {...}]. Your change wraps it in an object: { data: [{...}, {...}] }.

Your test uses Array.isArray(response.body) to check the response. An object is not an array, so this assertion will fail.

  • Save app.js.
Push the broken code and watch the pipeline fail

Before you push, think about what you expect to happen. The pipeline runs your tests on every push. You just broke the response shape that the tests check. What do you think the Actions tab will show this time?

  • Stage, commit, and push the broken code by running these commands:
git add app.js
git commit -m "break: wrap tasks in object"
git push
  • Switch to your browser and open the Actions tab in your GitHub repository.
  • Wait for the new workflow run to appear. It takes about 30-60 seconds to finish.

You should see a red X next to the latest commit. The pipeline caught the bug.

Still seeing a green checkmark?

Make sure you saved app.js before committing. Run git diff to confirm your change is tracked.

If the run is still in progress (yellow dot), give it another minute to complete.

Help me figure out why my workflow is still green

Read the error message in the Actions logs

A red X tells you something failed. The logs tell you exactly what went wrong and where. Learning to read CI error output is one of the most valuable skills this step teaches.

  • Click on the failed workflow run (the one with the red X).
  • Click on the test job in the left sidebar.
  • Expand the Run tests step to see the full output.

You should see the test failure indicating that the response body is not an array. The log shows which test failed and why the assertion did not pass.

What should I look for in the logs?

Look for the test name that failed: GET /tasks returns a list of tasks. The output shows the assertion that did not match.

The test expected Array.isArray(response.body) to be true, but received false because you changed the response from a plain array to an object with a data key.

Why does this matter?

This is Continuous Integration doing its job. Without the pipeline, this bug would have reached production silently. A user calling GET /tasks would get an unexpected response shape, and you might not find out until someone reports it.

The pipeline caught it in under a minute, before anyone else ever saw the broken code running live.

The pipeline did exactly what it should. It blocked a broken change from going any further. In the next step, you will fix the bug, watch the pipeline go green again, and connect automatic deployment so that passing code goes live.

Fix the Bug and Deploy to Render

In the last step, your pipeline caught a deliberate bug and blocked it from reaching production. That red X proves the system works.

Now you will fix the bug and connect the final piece of the loop: automatic deployment. When tests pass on the main branch, the pipeline will deploy your API to a live URL on Render without any manual steps.

In this step, get ready to:
  • Revert the bug so tests pass again.
  • Create a free Render web service connected to your GitHub repo.
  • Add a deploy hook to your workflow so passing tests trigger a live deployment.
Revert the bug so tests pass again

The bug you introduced in the previous step changed the response format of GET /tasks. You need to revert it so the test suite passes and the pipeline goes green.

  • In VS Code, open app.js and find this line inside the GET /tasks route:
  res.json({ data: tasks });
  • Replace { data: tasks } with just tasks, so the line now looks like this:
  res.json(tasks);
  • Save app.js.
  • Confirm the tests pass locally by running:
npx jest

You should see all three tests pass with green output.

Tests still failing?

Make sure you removed the entire { data: } wrapper and are left with just res.json(tasks).

Check that you saved app.js before re-running the tests. Help me fix my failing tests

  • Push the fix to GitHub by running these commands:
git add .
git commit -m "Fix GET /tasks response format"
git push

Before you check the Actions tab, what do you expect to see this time?

  • Open the Actions tab in your GitHub repository.
  • Click on the latest workflow run.

You should see a green checkmark. The tests pass and your pipeline is healthy again.

Still seeing a red X?

Click on the failed job to read the error output. Confirm your local tests pass with npx jest before pushing.

Make sure you pushed to the main branch, since that is the branch the workflow triggers on. Help me understand why my pipeline is still failing

Create a free Render web service

Your tests pass and the pipeline is green. Now you need somewhere for the API to live on the internet.

  • Open the Render Dashboard at https://dashboard.render.com.
  • Click + New in the top-right corner and select Web Service.
  • Select Git Provider and connect your GitHub account if you have not already.
  • Find your task tracker repository in the list and click Connect.
  • Fill in the service creation form with these values:
  • Name: use a name that matches your repo (this becomes part of your public URL).
  • Branch: main.
  • Language: Node.
  • Build Command: npm install.
  • Start Command: node index.js.
  • Scroll down and make sure the plan is set to Free.
  • Click Create Web Service.

Render kicks off its first build and deploy. You can watch the progress on the service's Events page.

Why use a deploy hook instead of Render's auto-deploy?

Render can auto-deploy on every push, but that skips your CI pipeline entirely. Using a deploy hook from GitHub Actions means deployment only happens AFTER your tests pass.

You will disable auto-deploy next and replace it with a hook that your pipeline controls.

What about auto-deploy?

By default, Render auto-deploys on every push to your linked branch. Since you want deployment to happen only after tests pass in your pipeline, you can leave auto-deploy on for now. The deploy hook will trigger an additional deploy when CI succeeds, which is the behaviour you want for a CI/CD loop.

  • Wait for the initial deploy to finish (this takes 1-2 minutes on the free tier).
  • Once the deploy succeeds, copy your service's public URL from the top of the Render dashboard page (it looks like https://your-service-name.onrender.com).
  • Visit https://your-service-name.onrender.com/tasks in your browser.

You should see the JSON array of tasks from your API, live on the internet.

Seeing an error or empty page?

If the deploy failed, check the Events page in Render for error logs. Make sure your Build Command is npm install and your Start Command is node index.js.

If you see a loading spinner, your free-tier service might still be building. Wait another minute and refresh. Help me debug my Render deployment

Add the deploy hook to your workflow

Your API is live, but right now Render deploys on every push regardless of whether tests pass. You want deployment to be triggered by your GitHub Actions pipeline only after a successful test run on the main branch.

  • In the Render Dashboard, open your service and click the Settings tab.
  • Scroll down to find the Deploy Hook section.
  • Copy the deploy hook URL. Keep this value secret.

What is a deploy hook?

A deploy hook is a secret URL unique to your Render service. Sending a simple GET or POST request to it triggers a new deploy. No special headers or authentication are needed beyond knowing the URL itself.

Now store this URL as a GitHub Secret so your workflow can use it without exposing it in code.

  • Go to your GitHub repository in your browser.
  • Click Settings in the repository navigation bar.
  • In the left sidebar under Security, select Secrets and variables, then click Actions.
  • Click the Secrets tab.
  • Click New repository secret.
  • In the Name field, enter RENDER_DEPLOY_HOOK_URL.
  • In the Secret field, paste the deploy hook URL you copied from Render.
  • Click Add secret.

Why store it as a secret?

Anyone who knows your deploy hook URL can trigger a deploy. GitHub Secrets are encrypted and never exposed in logs or code, so your URL stays safe.

Now add a deploy job to your workflow that uses this secret.

  • In VS Code, open .github/workflows/ci.yml.
  • Find the end of the test job (after the Run tests step).
  • Add the following deploy job below the test job:
  deploy:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Deploy to Render
        env:
          deploy_url: ${{ secrets.RENDER_DEPLOY_HOOK_URL }}
        run: curl "$deploy_url"

What does this job do?

  • needs: test means the deploy job only starts after the test job passes.
  • The if condition ensures it only runs on pushes to main, not on pull requests.
  • curl "$deploy_url" sends a request to the Render deploy hook, triggering a fresh deploy.
  • Save ci.yml.
  • Push the updated workflow by running:
git add .
git commit -m "Add deploy job to CI/CD pipeline"
git push

Before you check the Actions tab, what do you expect to see differently this time compared to the last green run?

  • Open the Actions tab and click the latest workflow run.

You should see two jobs: test and deploy. Both should have green checkmarks.

  • Wait 1-2 minutes for Render to process the deploy hook.
  • Visit your Render URL again at /tasks (for example, https://your-service-name.onrender.com/tasks).

You should see the JSON response from your live API. Your full CI/CD loop is now complete: push code, tests run, deploy happens automatically.

Deploy job passed but site is not updating?

The Render free tier spins down after 15 minutes of inactivity and takes about 1 minute to spin back up. Give it another minute and refresh.

If the deploy job shows a green check but Render did not redeploy, confirm the RENDER_DEPLOY_HOOK_URL secret is set correctly. Check that the secret name matches exactly (case-sensitive). Help me troubleshoot my Render deploy hook

✔️ Awesome, I've got everything!

Great. Double check you have saved both app.js and .github/workflows/ci.yml.

ⓧ I'd like to double check the full code

Here is the full app.js after reverting the bug:

const express = require("express");
const app = express();

app.use(express.json());

let tasks = [
  { id: 1, title: "Learn CI/CD", completed: false },
  { id: 2, title: "Set up GitHub Actions", completed: false },
];

let nextId = 3;

app.get("/tasks", (req, res) => {
  res.json(tasks);
});

app.post("/tasks", (req, res) => {
  const { title } = req.body;
  if (!title) {
    return res.status(400).json({ error: "Title is required" });
  }
  const task = { id: nextId++, title, completed: false };
  tasks.push(task);
  res.status(201).json(task);
});

And the second half of app.js:

app.patch("/tasks/:id/complete", (req, res) => {
  const id = parseInt(req.params.id);
  const task = tasks.find((t) => t.id === id);
  if (!task) {
    return res.status(404).json({ error: "Task not found" });
  }
  task.completed = true;
  res.json(task);
});

module.exports = app;

Here is the full .github/workflows/ci.yml:

name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "22"
          cache: "npm"

And the rest of ci.yml:

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

  deploy:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Deploy to Render
        env:
          deploy_url: ${{ secrets.RENDER_DEPLOY_HOOK_URL }}
        run: curl "$deploy_url"

You have a fully working CI/CD pipeline. Every push to main runs tests, and if they pass, your API deploys itself to a live URL. Next up: a secret mission to make your pipeline even stronger.

Secret mission

Add Linting and Branch Protection

Your pipeline tests code correctness, but what about code style? Production pipelines often include a linting step that catches formatting issues, unused variables, and other code smells before they pile up. In this mission, you'll add ESLint to the pipeline and configure GitHub branch protection so no code can reach main without passing CI.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Render free tier and a free GitHub repository, so there are no ongoing costs.

Resources you used:

  • Render free web service (your live API).
  • GitHub repository (stores your code and Actions workflow).
  • Local project folder (task-tracker-cicd on your Desktop).

Keep everything running

No action needed. Choose this if you are still actively building or want your API available.

  • The Render free tier costs nothing. Your API spins down after 15 minutes of inactivity and wakes on the next request (takes about 1 minute).
  • Your GitHub repository remains free for public repos with unlimited Actions minutes.
  • Your local project folder stays on your Desktop for future edits.

Pause - I'll come back to this later

Free up local memory without deleting anything permanently. Render suspends free services automatically when idle.

  • Stop any running local server by pressing Ctrl+C in your terminal.
  • No action needed on Render. Free services spin down automatically after 15 minutes of inactivity.
  • Your GitHub repository and Actions workflows remain intact and free.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete your Render web service: Open the Render Dashboard, click your service, navigate to its Settings page, scroll to the bottom, and click Delete Web Service. Confirm the deletion when prompted.
  • Delete your GitHub repository (optional): On GitHub, open your repository and click Settings. Scroll down to the Danger Zone section and click Delete this repository. Click I want to delete this repository, then I have read and understand these effects. Type your repository name to confirm and click Delete this repository.
  • Delete your local project folder by running this in your terminal:

macOS

cd ~/Desktop
rm -rf task-tracker-cicd

Windows

cd ~\Desktop
Remove-Item -Recurse -Force task-tracker-cicd

Nice Work!

Nice Work!

Nice work! You just built a complete CI/CD pipeline that tests your code automatically and deploys it to a live URL when everything passes.

You've learned how to:

  • Build a Node.js Express API with automated tests using Jest and Supertest that validate your endpoints.
  • Create a GitHub Actions workflow that runs tests on every push and triggers a Render deploy hook when they pass on main.
  • Experience a failing pipeline catching a bug before it reaches production, proving the value of Continuous Integration.
  • Secret Mission: Added ESLint to the pipeline and configured branch protection to enforce passing checks before merging.

Ready to quiz yourself?