Skip to content

Lesson 2 — Continuous Integration & Continuous Deployment (CI/CD) ​

In Lesson 1, you created a Node.js/Express API, pushed it to GitHub, and deployed it on Render. Every time you change the application, you need to know whether it still works.

You could test it manually before each push, but that is easy to forget. In this lesson, you will add automated tests and make GitHub run them when you push code or open a pull request.

Your goal is to build a repeatable process that answers a simple question:

Is this change safe enough to move to the next step?

Continue with your Lesson 1 project

Use the deployment-workshop repository you created previously. Keep the Netlify website and the Render API; you do not need to create new hosting services for this lesson.

If your project already has more features than the Lesson 1 example, you can adapt the tests to your existing routes.

Learning outcomes ​

By the end of this lesson, you should be able to:

  1. Explain Continuous Integration, Continuous Delivery, and Continuous Deployment, and identify how they differ.
  2. Describe a CI workflow using events, jobs, runners, steps, and status checks.
  3. Write and run automated API tests using Vitest and Supertest.
  4. Configure a GitHub Actions workflow to install dependencies, validate code, and run tests automatically.
  5. Investigate a failed CI run and fix the problem.
  6. Explain how successful CI checks can act as a gate before an application is deployed on Render.

What you need ​

Tool or accountWhy you need it
Node.js 22.12+ and npmRun the API and the current version of Vitest
Git and GitHubManage the repository and trigger workflows
GitHub ActionsRun automated checks on GitHub
VitestWrite and execute tests
SupertestMake HTTP requests to the Express app during tests
A code editor and terminalEdit files and run commands
Render account (optional for the final activity)Connect CI results to the API you deployed in Lesson 1

About the Node.js version

Run node --version to check your installation. Recent Vitest versions require Node.js 22.12 or newer. If your version is older, update Node.js before proceeding. Use the same major Node.js version locally and in GitHub Actions for consistent results.

What you will build ​

You will turn your GitHub repository into an automatically checked project:

text
Your computer
    │
    ├── src/app.js              ← Express routes
    ├── src/server.js           ← Starts the HTTP server
    ├── tests/app.test.js       ← Vitest + Supertest checks
    └── .github/workflows/ci.yml
                │
                ▼
             GitHub
                │
          Push / Pull request
                │
                ▼
          GitHub Actions
                │
       Install → Check → Test
                │
          Pass or fail
                │
                ▼
      Optional: Render deploys
      only after CI checks pass

Important: The GitHub Actions workflow in this lesson performs CI only. It does not contain credentials or commands to deploy your app. Later in the course, you will build deployment automation. For now, Render can perform its own deployment once the tests pass.


Part 1 — Understand CI/CD ​

1. Why automate checks? ​

Imagine that two developers are working on the same API. One updates /health; the other adds a new endpoint. Their changes may work individually but cause problems when combined.

Continuous Integration (CI) is the practice of integrating code changes frequently and checking them automatically. A typical CI workflow:

  1. Retrieves a specific version of your source code.
  2. Installs the dependencies required by the project.
  3. Performs validation, such as syntax checks or a build.
  4. Runs automated tests.
  5. Reports whether the checks passed or failed.

Because the same checks run repeatedly, problems are easier to spot before changes reach users.

2. CI vs. Continuous Delivery vs. Continuous Deployment ​

These terms are related, but they do not mean exactly the same thing.

PracticeWhat happens?Example
Continuous IntegrationCode is automatically checked as it changes.GitHub runs Vitest on each pull request.
Continuous DeliverySuccessfully checked code is kept ready for release, with deployment typically requiring an explicit approval or action.A verified version is ready for someone to release.
Continuous DeploymentChanges that pass the required checks are automatically deployed without a separate manual release decision.A merged change passes CI and is then deployed automatically.

A simple model is:

text
Code change
    │
    ▼
Continuous Integration
    └── Install → Validate → Test
                        │
                   Checks pass
                        │
                        ▼
Continuous Delivery
    └── Version is ready to deploy
                        │
               Manual approval?
                /             \
              Yes             No
               │               │
         Manual release   Automatic release
                               │
                               ▼
                       Continuous Deployment

Where does Render fit?

In Lesson 1, Render could redeploy your application after a push to main. That is an automatic deployment trigger, but it does not prove your changes passed automated tests.

In this lesson, you will add checks first. If you configure Render to wait for successful CI checks, you have a simple tested deployment path.

3. Understand a CI workflow ​

A GitHub Actions workflow uses several important concepts:

TermMeaningExample in this lesson
WorkflowA YAML file describing an automation..github/workflows/ci.yml
Event / triggerSomething that starts a workflow.A push to main or a pull request targeting main
JobA group of steps run on the same runner.The test job
RunnerThe computer where a job executes.A GitHub-hosted Ubuntu runner
StepA command or reusable action within a job.npm ci or npm test
ActionA reusable component in a workflow.actions/checkout, actions/setup-node
Check resultThe reported outcome of a job or workflow.Success or failure on a commit / pull request

Think about it ​

  1. Why should the checks run on GitHub instead of only on your laptop?
  2. Would a successful dependency installation prove that your API works?
  3. What should happen if a test fails?
Reveal suggested answers
  1. Everyone receives consistent feedback on the committed code, even if someone forgets to run the tests locally.
  2. No. Installing dependencies only confirms that the dependencies could be installed; it does not verify application behavior.
  3. The CI job should fail, the failure should be investigated, and the change should not be treated as verified.

Part 2 — Add Automated Tests with Vitest ​

4. Start with the API from Lesson 1 ​

Open your deployment-workshop project and install its dependencies if needed:

bash
cd deployment-workshop
npm ci

Your project should contain the Express server from Lesson 1:

text
deployment-workshop/
├── site/
│   └── index.html
├── src/
│   └── server.js
├── package.json
└── package-lock.json

The original src/server.js defines routes and starts listening for requests. That works for deployment, but it makes testing less convenient: importing the server file would also start the network listener.

You will separate those two responsibilities.

5. Separate the Express app from the listener ​

Create src/app.js:

js
import express from 'express';

const app = express();

app.use(express.json());

app.get('/', (_req, res) => {
  res.json({ message: 'Hello from Render!' });
});

app.get('/health', (_req, res) => {
  res.status(200).json({ status: 'ok' });
});

export default app;

Replace src/server.js with:

js
import app from './app.js';

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

app.listen(port, '0.0.0.0', () => {
  console.log(`API listening on port ${port}`);
});

Now the files have separate purposes:

  • src/app.js describes the Express application and its routes.
  • src/server.js starts the HTTP listener when you run the app.
  • Your tests can import app without having to start and stop a long-running server process.

Check that you did not change the API's behavior:

bash
npm start

Open http://localhost:3000/health and confirm that you still receive:

json
{"status":"ok"}

Stop the server with Ctrl+C before continuing.

Your Render configuration stays the same

You are still using npm start to run src/server.js, so the start command you configured in Lesson 1 does not need to change.

6. Install Vitest and Supertest ​

In the same project, run:

bash
npm install --save-dev vitest supertest

Both packages are development dependencies. They help you verify code while developing and do not need to run inside the production API process.

Add scripts to package.json:

bash
npm pkg set "scripts.test=vitest run"
npm pkg set "scripts.test:watch=vitest"
npm pkg set "scripts.check=node --check src/app.js && node --check src/server.js"

Your scripts section should include these commands (alongside any scripts you already have):

json
{
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "check": "node --check src/app.js && node --check src/server.js"
  }
}

Do not replace your entire package.json with the snippet above; it only shows the relevant scripts.

CommandPurpose
npm testRun all tests once and return a pass/fail result. Ideal for CI.
npm run test:watchWatch files and rerun affected tests as you work.
npm run checkCheck the syntax of the two JavaScript source files.

Why use vitest run in CI?

The interactive Vitest watch mode is helpful on your laptop, but a CI job must finish so GitHub can report a result. vitest run executes the tests once and exits.

7. Write your first API tests ​

Create a tests folder and a file named tests/app.test.js:

js
import { describe, expect, it } from 'vitest';
import request from 'supertest';
import app from '../src/app.js';

describe('GET /', () => {
  it('returns the welcome message', async () => {
    const response = await request(app).get('/');

    expect(response.status).toBe(200);
    expect(response.body).toEqual({
      message: 'Hello from Render!',
    });
  });
});

describe('GET /health', () => {
  it('reports that the API is healthy', async () => {
    const response = await request(app).get('/health');

    expect(response.status).toBe(200);
    expect(response.body).toEqual({ status: 'ok' });
  });

  it('returns a JSON response', async () => {
    const response = await request(app).get('/health');

    expect(response.headers['content-type']).toMatch(/json/);
  });
});

describe('Unknown routes', () => {
  it('returns 404 for an unknown URL', async () => {
    const response = await request(app).get('/does-not-exist');

    expect(response.status).toBe(404);
  });
});

Run the tests:

bash
npm test

You should see four passing tests.

How does this work? ​

js
const response = await request(app).get('/health');

Supertest sends a request to the Express app in the test process. You do not need to run npm start in another terminal for these tests.

js
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'ok' });

Vitest compares the actual response with the response you expected. If the values do not match, the test fails.

The tests above are API endpoint tests: they check how the application responds to HTTP requests. As your project grows, you can also write unit tests for individual functions without making HTTP requests.

8. See a failing test ​

A test suite is most useful when you know that it can detect a problem.

Temporarily change the expected health status in tests/app.test.js:

js
expect(response.body).toEqual({ status: 'offline' });

Run:

bash
npm test

One test should now fail, because the real endpoint still returns {"status":"ok"}.

Change the assertion back to:

js
expect(response.body).toEqual({ status: 'ok' });

Run npm test again. Confirm that everything passes before committing your changes.

Keep the deliberate failure out of main

For now, make and fix the intentional failure locally. Later, you can explore failing CI runs safely using a separate feature branch and pull request. Do not intentionally push broken application code to the branch that Render automatically deploys.

Your testing checkpoint ​

  • [ ] npm start still starts the API.
  • [ ] I separated src/app.js from src/server.js.
  • [ ] npm test runs four API tests successfully.
  • [ ] I saw a test fail and then made it pass again.
  • [ ] npm run check completes successfully.

Part 3 — Automate Testing with GitHub Actions ​

9. Create a CI workflow ​

GitHub Actions discovers workflow files inside the .github/workflows/ directory of your repository.

Create .github/workflows/ci.yml:

yaml
name: Node.js CI

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

permissions:
  contents: read

jobs:
  test:
    name: Test Express API
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v7

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

      - name: Install dependencies
        run: npm ci

      - name: Check JavaScript syntax
        run: npm run check

      - name: Run Vitest tests
        run: npm test

Workflow file location

The .github directory belongs in your application repository, next to its package.json. Do not place this workflow inside the VitePress documentation website unless the documentation site is also your API repository.

10. Read the workflow step by step ​

When does it run?

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

A workflow is triggered when you push to main or create/update a pull request targeting main. Other branches can run checks through their pull requests without being deployed.

Where does it run?

yaml
runs-on: ubuntu-latest

GitHub provides a runner using Ubuntu. You do not need to keep your computer switched on.

What gets installed?

yaml
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
  with:
    node-version: '22'
    cache: npm

The checkout action retrieves the repository. The setup-node action selects Node.js 22 and enables npm's package download cache.

How does it check your work?

yaml
- run: npm ci
- run: npm run check
- run: npm test
  • npm ci installs dependencies from package-lock.json. It requires the lockfile to be committed and consistent with package.json.
  • npm run check validates JavaScript syntax. This example Express API does not need a compilation/build step.
  • npm test runs your Vitest suite and fails the job if a test fails.

Why limit permissions?

yaml
permissions:
  contents: read

The job needs to read your source code, but does not need permission to change repository content. This is a safer default for a test-only workflow.

About action versions

The @v7 values in this workflow refer to the major versions of GitHub's official checkout and setup-node actions. Official actions evolve, so check their documentation when updating your pipeline. For a production system, consider pinning actions to reviewed commit SHAs.

11. Run the workflow on GitHub ​

First, check everything locally:

bash
npm run check
npm test

Confirm that the following files exist:

text
deployment-workshop/
├── .github/
│   └── workflows/
│       └── ci.yml
├── site/
│   └── index.html
├── src/
│   ├── app.js
│   └── server.js
├── tests/
│   └── app.test.js
├── package.json
└── package-lock.json

Commit and push your changes:

bash
git add .github/workflows/ci.yml src/app.js src/server.js tests/app.test.js package.json package-lock.json
git commit -m "Add automated API tests and GitHub Actions CI"
git push origin main

If your default branch is not named main, update the workflow's branch filters and the push command to match your repository.

In GitHub:

  1. Open your deployment-workshop repository.
  2. Select the Actions tab.
  3. Open the Node.js CI workflow.
  4. Select the most recent run and open the Test Express API job.
  5. Inspect the log for each step and verify that the job passed.

A successful run confirms that GitHub checked the committed version of your project using a clean installation, rather than relying on dependencies already installed on your laptop.

12. Check changes using a pull request ​

Create a feature branch:

bash
git switch -c feature/more-api-tests

Add this new test to the bottom of tests/app.test.js:

js
describe('Response headers', () => {
  it('serves JSON on the root route', async () => {
    const response = await request(app).get('/');

    expect(response.headers['content-type']).toMatch(/json/);
  });
});

Run the tests and push the branch:

bash
npm test
git add tests/app.test.js
git commit -m "Test JSON response headers"
git push -u origin feature/more-api-tests

Open a pull request from feature/more-api-tests into main on GitHub. You should see the CI checks appear on the pull request. This lets you review the results before merging.

Optional challenge — Observe a failed pull request check

On the feature branch (not on main), change the new test to expect a header pattern the API does not return:

js
expect(response.headers['content-type']).toMatch(/text\/plain/);

Commit and push that test change. The pull request's CI should fail. Open the failed job, find the assertion, restore /json/, and push a fix. The next run should pass.

Do not merge until the checks are passing. If you have already enabled Render's “After CI Checks Pass” setting, it still applies to your linked deployment branch; a pull request on another branch does not deploy that branch to your production service.

Once the pull request passes, merge it on GitHub. Then return your local checkout to main and download the merged changes before starting another exercise:

bash
git switch main
git pull --ff-only origin main

Commit any unfinished feature-branch work before switching. If the pull reports diverging branches, resolve that Git history issue before continuing. Merging on GitHub does not automatically switch or update your local branch.

Your CI checkpoint ​

  • [ ] .github/workflows/ci.yml is committed to GitHub.
  • [ ] GitHub Actions ran the workflow for main.
  • [ ] I can locate each step's log and identify a passing check.
  • [ ] I opened a pull request and saw its CI status.
  • [ ] I can explain why npm ci uses package-lock.json.

Part 4 — Connect CI to Your Render Deployment ​

13. Why passing tests should matter before deployment ​

Your API from Lesson 1 may still be configured to deploy on every commit to main. If so, Render can start deploying a commit even if GitHub Actions later reports a failing test.

You can change this behavior by making Render wait for CI checks.

text
New commit on main
        │
        ▼
  GitHub Actions
     Run tests
      /     \
    Pass    Fail
     │        │
     ▼        ▼
  Render     No automatic
  deploy     Render deploy

14. Wait for CI checks before Render auto-deploys ​

If you still have the Render web service from Lesson 1:

  1. Open the Render dashboard.
  2. Select your API service.
  3. Open Settings and find the Auto-Deploy setting.
  4. Select After CI Checks Pass.
  5. Save your changes.
  6. Push a passing change to main (or merge a pull request whose tests passed).
  7. Compare the GitHub Actions result with the deployment activity in Render.
  8. Verify the deployed /health endpoint after deployment finishes.

Render's exact dashboard wording and plan options can change. Its deployment documentation describes the currently supported auto-deploy behavior.

Know what this setting does—and does not do

  • On Commit: Render starts an automatic deploy when the linked branch changes; it does not wait for CI results.
  • After CI Checks Pass: Render waits for the detected checks to pass before automatically deploying that commit.
  • Off: Render does not automatically deploy changes to the linked branch.

This does not mean the GitHub Actions workflow itself deploys the code. Render performs the deployment, and GitHub Actions supplies the checks.

An automated test suite also cannot guarantee that an application has no bugs. You still need to verify the live service after deployment.

15. Validate the full path ​

Once your pull request has passed CI and has been merged, check the following:

  1. The main branch contains the expected commit.
  2. GitHub Actions reports a successful run for that commit.
  3. Render reports a successful deployment of the same commit.
  4. Your public /health route returns:
json
{"status":"ok"}
  1. The root route still returns the expected welcome message.

You now have a CI pipeline and a deployment platform that can use its results. In a later lesson, you will automate the deployment process more directly and work with Docker-based deployments.


Part 5 — Troubleshooting and Good Practices ​

16. Common problems ​

ProblemWhat to check
npm test says a script is missingConfirm package.json contains "test": "vitest run".
Cannot find package 'vitest'Run npm install --save-dev vitest supertest and check package-lock.json.
Test run does not finishUse vitest run rather than watch mode in your CI script.
Tests fail when importing the appConfirm tests import src/app.js, not the file that starts the listener.
Test expects the wrong responseCompare the assertion with the actual route response.
npm ci fails on GitHubCommit an up-to-date package-lock.json; run npm install locally if it is out of sync.
No workflow appears in GitHubCheck .github/workflows/ci.yml, the workflow syntax, and repository Actions settings.
Workflow does not run on a branch pushThis workflow runs on pushes to main, and on pull requests targeting main.
Render deploys even when tests failCheck whether Auto-Deploy is still set to On Commit.

17. Good habits to keep ​

  • Run tests locally before pushing, so you catch easy failures early.
  • Keep tests deterministic. The same code should not pass or fail depending on unrelated external services.
  • Avoid production credentials in tests. Today's test suite does not need any secrets or live database connection.
  • Use pull requests for changes you want to review before merging.
  • Inspect failed logs rather than repeatedly rerunning a broken workflow without changing anything.
  • Keep CI permissions minimal. A test workflow should not need production deployment secrets.
  • Verify production after deploying. Passing tests is one piece of evidence, not a substitute for checking the deployed API.

Final Exercise — Prove Your CI Pipeline Works ​

Complete these tasks using your deployment-workshop project:

  1. From your updated local main, create a feature branch with git switch -c feature/version-endpoint.
  2. Add one new API route, such as GET /version, that returns JSON describing the current API version.
  3. Write at least two tests for that route: one for its HTTP status and another for its response body.
  4. Run npm run check and npm test locally, commit and push the branch, and open a pull request targeting main.
  5. Confirm that GitHub Actions runs and passes.
  6. Merge the pull request, then verify that the new route is available on Render if deployment is configured.
Example solution — A /version endpoint

Add this route to src/app.js, before export default app:

js
app.get('/version', (_req, res) => {
  res.json({ version: '1.0.0' });
});

Add these tests to tests/app.test.js:

js
describe('GET /version', () => {
  it('returns HTTP 200', async () => {
    const response = await request(app).get('/version');
    expect(response.status).toBe(200);
  });

  it('returns the API version', async () => {
    const response = await request(app).get('/version');
    expect(response.body).toEqual({ version: '1.0.0' });
  });
});

Once the checks pass and your changes are deployed, you can visit https://YOUR-SERVICE-NAME.onrender.com/version. Replace YOUR-SERVICE-NAME with your Render service subdomain.

After merging this exercise's pull request, prepare your local checkout for Lesson 3:

bash
git switch main
git pull --ff-only origin main

Later commands such as git push origin main push your local main branch, even if you are currently working on a different branch. Finish and merge feature-branch work, then update local main before following those commands.

Final checkpoint ​

  • [ ] My API routes are testable without starting a server manually.
  • [ ] I can run and understand the Vitest test suite.
  • [ ] GitHub Actions automatically checks changes to my repository.
  • [ ] I understand how to diagnose a failed CI job.
  • [ ] I can explain the difference between CI, Continuous Delivery, and Continuous Deployment.
  • [ ] I understand how Render can wait for CI checks before deploying.

Knowledge Check ​

Try answering these questions before revealing the suggested answers.

  1. What is the main purpose of Continuous Integration?
  2. What is the difference between Continuous Delivery and Continuous Deployment?
  3. Why did you move the Express routes into src/app.js?
  4. Why do you use vitest run instead of watch mode in CI?
  5. What is the difference between npm install and npm ci in this workflow?
  6. What causes the GitHub Actions workflow to start?
  7. Where do you find a failed step's output in GitHub?
  8. Does a passing GitHub Actions workflow automatically deploy your code by itself?
  9. What Render setting prevents an automatic deploy when a CI check fails?
  10. Why should you still check /health after deploying a change?
Reveal suggested answers
  1. To integrate and automatically verify code changes frequently, helping teams catch problems early.
  2. Continuous Delivery keeps code ready for release, typically with a deliberate release action. Continuous Deployment automatically releases changes after required checks pass.
  3. So tests can import the Express app without creating a long-running HTTP listener.
  4. vitest run completes and returns a result; watch mode remains active waiting for file changes.
  5. npm ci performs a clean installation using the committed lockfile and fails if the lockfile is inconsistent. npm install may update dependencies and the lockfile.
  6. A push to main or a pull request targeting main.
  7. Open the repository's Actions tab, select the run and job, then inspect the relevant step.
  8. No. This workflow only installs dependencies, validates syntax, and runs tests. Render is still responsible for deployment.
  9. After CI Checks Pass.
  10. A green test run does not guarantee that production configuration, startup, routing, or networking all work as expected.

Summary ​

In this lesson, you:

  • Learned how CI, Continuous Delivery, and Continuous Deployment fit together.
  • Made your Express routes easy to test by separating app configuration from server startup.
  • Wrote Vitest + Supertest API tests and learned what a failing assertion looks like.
  • Created a GitHub Actions workflow that runs validation and tests on commits and pull requests.
  • Learned how Render can use CI checks to prevent failed changes from automatically deploying.

The key takeaway: Deployment automation becomes much safer when automated checks verify changes before they reach your users.

Before Lesson 3 ​

  • [ ] Keep your deployment-workshop project and its GitHub repository.
  • [ ] Keep the new src/app.js, tests/app.test.js, and .github/workflows/ci.yml files.
  • [ ] Make sure the test suite passes locally and on GitHub.
  • [ ] Check that your Render API still responds correctly.

Next: Lesson 3 — Containerization with Docker. You will package this same Node.js API into a Docker image and learn to run it in a container.

Further reading ​