Docker, GitHub Actions, and Render

Containerize a REST API and ship it with a full CI/CD pipeline.

Introduction

30 Second Summary

Every time you use an app, the code behind it had to travel from a developer's computer to a server somewhere in the world. Professional teams don't do this manually. They use automated pipelines that test and deploy code every time a change is made.

In this project, you will build a REST API, package it inside a Docker container, set up automated testing with GitHub Actions, and deploy it to a live URL on Render. By the end, pushing code to GitHub will automatically test and ship your app.

What You'll Build

A containerized Node.js API with a complete CI/CD pipeline that automatically tests and deploys your code on every push.

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

  • A Node.js/Express REST API with health and status endpoints, running inside a Docker container.
  • A GitHub Actions workflow that automatically runs tests on every push to your repository.
  • A live, publicly accessible URL on Render that auto-deploys whenever your CI/CD checks pass.
  • Secret Mission: Add a /metrics endpoint that tracks request counts and response times, and configure a second GitHub Actions job that builds and tags your Docker image on every release.

Are there any prerequisites?

You should have basic JavaScript/Node.js knowledge and familiarity with Git commands like commit and push. You'll also need a GitHub account, VS Code, Git, and Docker Desktop installed on your machine.

Set Up Your Tools

Every DevOps project starts with a clean foundation. Your containerized API needs a project directory, a package manager to handle dependencies, and version control to track changes.

In this step, you'll scaffold the devops-status-api project from scratch. You'll use Node.js to initialize the project, install Express as your web framework, and set up Git so your code is ready to push to GitHub later.

In this step, get ready to:
  • Create the devops-status-api project directory.
  • Initialize Node.js and install Express.
  • Set up Git version control with a .gitignore file.
Create the project directory and initialize Node.js

Your project needs its own folder on your Desktop so you can find it easily. You'll create the directory, initialize a Node.js project inside it, and install Express.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal (macOS) or PowerShell (Windows) and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir devops-status-api
cd devops-status-api
  • Confirm the folder exists by running:
ls

You should see the devops-status-api folder listed in the output before you cd into it. Now that you're inside, it will be empty.

Seeing 'No such file or directory'?

Make sure you ran cd ~/Desktop first. If your Desktop path is different (some Windows setups use OneDrive/Desktop), navigate to your actual Desktop path instead.

help me find my Desktop directory in the terminal

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

What does this command do?

npm init -y creates a package.json file with default values. This file tracks your project's name, version, and dependencies. The -y flag accepts all defaults so you skip the interactive prompts.

You should see output confirming that package.json was created, with "name": "devops-status-api" matching your folder name.

  • Install Express as your web framework by running:
npm install express

What does this command do?

npm install express downloads the Express package and adds it as a dependency in your package.json. It also creates a node_modules/ folder containing Express and all its sub-dependencies, plus a package-lock.json that locks exact versions.

You should see output showing packages being added, ending with a line like added X packages.

Seeing permission errors or EACCES?

On macOS or Linux, if you see permission errors, try prefixing the command with sudo. On Windows, try running your terminal as Administrator.

help me fix npm permission errors on my system

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

'code' command not recognized?

On macOS, you need to install the VS Code shell command first. Open VS Code manually, press Cmd+Shift+P, type Shell Command: Install 'code' command in PATH, and select it. Then try code . again.

On Windows, the VS Code installer usually adds this automatically. If it didn't, reinstall VS Code and check the Add to PATH option during setup.

help me open my project folder in VS Code

VS Code opens with your devops-status-api folder in the Explorer sidebar. You should see package.json, package-lock.json, and a node_modules folder listed.

Initialize Git and create a .gitignore file

Version control lets you track every change and push code to GitHub for your CI/CD pipeline later. You also need a .gitignore file to keep the massive node_modules/ folder out of your repository.

  • In your VS Code terminal (open it with Ctrl+` or Terminal > New Terminal from the menu), initialize a Git repository by running:
git init

You should see a message saying Initialized empty Git repository followed by your project path.

Why initialize Git now?

Setting up Git before writing any code means every file you create from this point forward is tracked. Later, you'll push this repository to GitHub where your CI/CD pipeline will watch for changes.

  • Create a .gitignore file by running:
echo "node_modules/" > .gitignore

What does .gitignore do?

The .gitignore file tells Git which files and folders to ignore. The node_modules/ folder contains thousands of dependency files that anyone can regenerate by running npm install. Keeping it out of Git makes your repository small and fast.

  • Verify your .gitignore was created by running:
cat .gitignore

You should see node_modules/ printed in your terminal.

Don't see the .gitignore file in VS Code?

Files starting with a dot are hidden by default on some systems. In VS Code's Explorer sidebar, you should still see it listed. If not, check that you ran the echo command from inside the devops-status-api directory.

help me find my .gitignore file

Double check your project setup

Your project should now have the correct files in place. Use the tabs below to confirm everything looks right.

✔️ Awesome, I've got everything!

Great. Double check you've saved all files, then continue to the validation below.

ⓧ I'd like to double check the full code

Here are the two files your project should have at this point. Your package.json may have additional fields like keywords, author, and license. That's fine. The important parts are the name, version, and dependencies fields.

{
  "name": "devops-status-api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "express": "^5.2.1"
  }
}
node_modules/

Your project scaffold is locked in. Next up, you'll build the actual REST API with health and status endpoints that your Docker container will serve.

Build the REST API

Your development environment is set up with Node.js, Express, and Git all in place.

Now it's time to write the actual API that you'll containerize and deploy through your pipeline. In this step, you'll build a REST API with health and status endpoints that report whether your app is running correctly.

In this step, get ready to:
  • Create the Express application with health and status endpoints.
  • Add npm scripts and verify the app runs locally.
  • Write automated tests for your endpoints.
Create the Express application

Your API needs three endpoints. A root route confirms the app is reachable. A health endpoint returns a structured status check. A status endpoint reports version, uptime, and timestamp.

  • In VS Code, create a new file called index.js in the root of your devops-status-api folder by pasting this code:
const express = require('express');

const app = express();
const PORT = process.env.PORT || 3000;
const startTime = Date.now();

// Root route - confirms the API is reachable
app.get('/', (req, res) => {
  res.json({ message: 'Welcome to the DevOps Status API' });
});

// Health endpoint - returns a simple status check
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'healthy' });
});

// Status endpoint - reports system info
app.get('/status', (req, res) => {
  res.json({
    version: '1.0.0',
    uptime: Math.floor((Date.now() - startTime) / 1000),
    timestamp: new Date().toISOString()
  });
});

// Start the server and export for testing
const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

module.exports = { app, server };

What does this code do?

This file creates your entire Express application with three routes:

  • GET / returns a welcome message so you can confirm the API is reachable.
  • GET /health returns { status: 'healthy' } with a 200 status code. Monitoring tools use endpoints like this to check if an app is alive.
  • GET /status returns the app version, how many seconds it has been running, and the current timestamp.

The PORT variable reads from an environment variable first, falling back to 3000. This lets cloud platforms like Render assign their own port. The module.exports at the bottom exports both app and server so your test file can import them later.

  • Save index.js.

You won't see output yet. In the next substep, you'll run this server and verify it responds.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

const express = require('express');

const app = express();
const PORT = process.env.PORT || 3000;
const startTime = Date.now();

app.get('/', (req, res) => {
  res.json({ message: 'Welcome to the DevOps Status API' });
});

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

app.get('/status', (req, res) => {
  res.json({
    version: '1.0.0',
    uptime: Math.floor((Date.now() - startTime) / 1000),
    timestamp: new Date().toISOString()
  });
});

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

module.exports = { app, server };
Add npm scripts and run the server

Your app code is ready, but you need a way to start it with a standard command. The scripts section in package.json defines shortcut commands that any developer on your team can run without memorizing file paths.

  • Open package.json in VS Code and replace its contents with:
{
  "name": "devops-status-api",
  "version": "1.0.0",
  "description": "A containerized REST API with CI/CD pipeline",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "express": "^5.2.1"
  }
}

What changed here?

  • "start": "node index.js" lets you run npm start to launch your server. This is the standard way Node.js apps are started, and it's what cloud platforms look for.
  • The description field now describes what this project is. You'll update the test script in the next substep once you've written your test file.
  • Save package.json.
  • Start your server by running this command in the VS Code terminal:
node index.js
  • Open your browser and visit http://localhost:3000. You should see a JSON response: {"message":"Welcome to the DevOps Status API"}.
  • Try http://localhost:3000/health and http://localhost:3000/status to see each endpoint respond with its own data.
  • Return to your terminal and press Ctrl+C to stop the server.

Not seeing any output in the browser?

  • Make sure you saved both index.js and package.json before running the command.
  • If you see Error: Cannot find module 'express', confirm that node_modules exists in your project folder. If not, run npm install again.
  • If port 3000 is already in use, stop whatever process is using it or change the port in index.js temporarily.

Help me debug why my Express server isn't responding at localhost:3000.

Write automated tests for your endpoints

A CI/CD pipeline needs automated tests to decide whether code is safe to deploy. Node.js 22 includes a built-in test runner, so you don't need to install a separate testing framework.

  • Create a new file called test.js in the root of your devops-status-api folder and paste this code:
const { describe, it, after } = require('node:test');
const assert = require('node:assert');
const { app, server } = require('./index.js');

describe('Health endpoint', () => {
  // Close the server after all tests finish
  after(() => {
    server.close();
  });

  // Test that /health returns 200 and the expected body
  it('should return 200 and healthy status', async () => {
    const response = await fetch('http://localhost:3000/health');
    assert.strictEqual(response.status, 200);
    const body = await response.json();
    assert.strictEqual(body.status, 'healthy');
  });

  // Test that /status returns version and uptime info
  it('should return status info', async () => {
    const response = await fetch('http://localhost:3000/status');
    assert.strictEqual(response.status, 200);
    const body = await response.json();
    assert.strictEqual(body.version, '1.0.0');
    assert.ok(body.uptime >= 0);
    assert.ok(body.timestamp);
  });
});

What does this test file do?

  • node:test and node:assert are built-in Node.js modules. No extra packages needed.
  • require('./index.js') imports your app and starts the server so the tests can make real HTTP requests against it.
  • The first test hits /health and checks that it returns a 200 status with { status: 'healthy' }.
  • The second test hits /status and verifies the response includes version, uptime, and timestamp.
  • after() shuts down the server once all tests complete so the process exits cleanly.
  • Save test.js.

Now you need to tell npm how to run your tests. You'll update the test script in package.json to use the Node.js built-in test runner.

  • In package.json, find this line in the scripts section:
"test": "echo \"Error: no test specified\" && exit 1"
  • Replace it with:
"test": "node --test test.js"
  • Save package.json.
  • Make sure your server from earlier is not still running (press Ctrl+C if it is). Then run your tests:
npm test
  • You should see output indicating both tests passed with a summary showing 2 tests, 2 passed, and 0 failed.

Tests failing or not running?

  • If you see EADDRINUSE, your server is still running from the previous substep. Press Ctrl+C in the terminal to stop it, then run npm test again.
  • If you see Error: no test specified, you haven't updated the test script in package.json yet. Make sure it reads "node --test test.js".
  • If tests fail with assertion errors, double check that your index.js routes return the exact values the tests expect (status: 'healthy' and version: '1.0.0').

Help me debug why my npm test command is failing.

✔️ Awesome, I've got everything!

Great. Make sure both test.js and package.json are saved before moving on.

ⓧ I'd like to double check the full code

const { describe, it, after } = require('node:test');
const assert = require('node:assert');
const { app, server } = require('./index.js');

describe('Health endpoint', () => {
  after(() => {
    server.close();
  });

  it('should return 200 and healthy status', async () => {
    const response = await fetch('http://localhost:3000/health');
    assert.strictEqual(response.status, 200);
    const body = await response.json();
    assert.strictEqual(body.status, 'healthy');
  });

  it('should return status info', async () => {
    const response = await fetch('http://localhost:3000/status');
    assert.strictEqual(response.status, 200);
    const body = await response.json();
    assert.strictEqual(body.version, '1.0.0');
    assert.ok(body.uptime >= 0);
    assert.ok(body.timestamp);
  });
});
{
  "name": "devops-status-api",
  "version": "1.0.0",
  "description": "A containerized REST API with CI/CD pipeline",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "node --test test.js"
  },
  "dependencies": {
    "express": "^5.2.1"
  }
}

Your API is built, running, and tested. Next up, you'll package it into a Docker container so it can run anywhere.

Containerize with Docker

Your API works locally and your tests pass. Now you need a way to guarantee it runs the same way on every machine, from your laptop to a cloud server.

Docker solves this by packaging your app, its dependencies, and its runtime environment into a single portable unit called a container. In this step, you'll write a Dockerfile, build an image, and run your API inside a container.

In this step, get ready to:
  • Write a Dockerfile that defines how to build your app image.
  • Build the image and verify it exists locally.
  • Run the container and confirm the API responds.
Write the Dockerfile

A Dockerfile is a recipe that tells Docker exactly how to build your application image. Each line is an instruction that adds a layer to the image.

  • In VS Code, create a new file called Dockerfile in the root of your devops-status-api folder (no file extension).
  • Add the following code to your Dockerfile:
FROM node:22-alpine

WORKDIR /app

COPY package.json package-lock.json ./

RUN npm install --omit=dev

COPY . .

EXPOSE 3000

CMD ["node", "index.js"]

What does this Dockerfile do?

  • FROM node:22-alpine starts from a lightweight Linux image with Node.js 22 pre-installed.
  • WORKDIR /app sets the working directory inside the container.
  • COPY package.json package-lock.json ./ copies only the dependency files first, so Docker can cache the install step.
  • RUN npm install --omit=dev installs production dependencies only, keeping the image small.
  • COPY . . copies your application source code into the container.
  • EXPOSE 3000 documents which port the container listens on.
  • CMD ["node", "index.js"] defines the command that runs when the container starts.
  • Save Dockerfile.

Why copy package files separately?

Docker caches each layer. By copying package.json and running npm install before copying your source code, Docker only re-installs dependencies when your package files change. If you only edit index.js, the install layer stays cached and your builds are much faster.

Don't have a package-lock.json?

The package-lock.json file is auto-generated when you run npm install. If you don't see it in your project folder, run npm install once in your terminal and it will appear.

Help me figure out why my package-lock.json is missing

✔️ Awesome, I've got everything!

Great. Make sure you've saved your Dockerfile before moving on.

ⓧ I'd like to double check the full code

FROM node:22-alpine

WORKDIR /app

COPY package.json package-lock.json ./

RUN npm install --omit=dev

COPY . .

EXPOSE 3000

CMD ["node", "index.js"]
Create .dockerignore and build the image

Before building, you need to tell Docker which files to skip. Without a .dockerignore file, Docker copies everything into the image, including your local node_modules and .git history.

  • Create a new file called .dockerignore in the root of your devops-status-api folder.
  • Add the following content to .dockerignore:
node_modules
.git
.github
.gitignore
*.md

What does this exclude?

These entries prevent Docker from copying your local node_modules (the container installs its own), your Git history, your GitHub Actions workflows, and any README files into the image. This keeps the image small and focused on production code only.

  • Save .dockerignore.

✔️ Awesome, I've got everything!

Great. Make sure you've saved your .dockerignore before moving on to the build step.

ⓧ I'd like to double check the full code

node_modules
.git
.github
.gitignore
*.md

Now you're ready to build the Docker image from your Dockerfile.

  • Make sure Docker Desktop is running on your machine.
  • Build your Docker image by running this command in your terminal:
docker build -t devops-status-api .

What does this command do?

  • docker build reads your Dockerfile and creates an image layer by layer.
  • -t devops-status-api gives the image a name (tag) so you can reference it later.
  • . tells Docker to look for the Dockerfile in the current directory.
  • Verify your image was built by running:
docker images

You should see devops-status-api listed in the output with a tag of latest and a size around 60-80 MB.

Build failed or image not showing?

  • Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.
  • Confirm you're running the docker build command from inside your devops-status-api folder where the Dockerfile lives.
  • If you see an error about package-lock.json not found, run npm install first to generate it.

Help me debug my Docker build error

Run the container and verify the API

Your image is built. Now you'll run it as a container and confirm the API responds just like it did locally.

Free up port 3000 first

If you still have node index.js running from the previous step, stop it with Ctrl+C in that terminal. Docker needs port 3000 to be free.

  • Start your container by running:
docker run -d -p 3000:3000 devops-status-api

What does this command do?

  • -d runs the container in detached mode (in the background).
  • -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container.
  • devops-status-api is the image name you tagged during the build.

The command prints a long container ID when it starts successfully.

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

You should see {"status":"healthy"} in your browser. Your API is now running inside a Docker container.

  • Now stop the running container. First, find the container ID by running:
docker ps

You'll see a table with your running container. Copy the value in the CONTAINER ID column.

  • Stop the container by running:
docker stop [[CONTAINER_ID="your container ID from docker ps"]]

Docker sends a stop signal to the container and shuts it down gracefully.

Container not responding on port 3000?

  • Make sure you don't have another process using port 3000. Run docker ps to confirm your container is actually running.
  • If the container exits immediately, check its logs with docker logs followed by the container ID from docker ps -a.
  • Verify Docker Desktop is running and hasn't paused or stopped.

Help me figure out why my Docker container isn't responding

Your app is officially containerized. Anyone with Docker can run your image and get the exact same behavior, regardless of their operating system or installed software. Next up, you'll push this project to GitHub and set up a CI pipeline that tests your code automatically.

Set Up GitHub Actions CI

Your app is containerized and running inside Docker. But right now, you're relying on yourself to remember to run the tests before shipping code.

In a professional workflow, tests run automatically every time you push code. This is called CI/CD (Continuous Integration / Continuous Delivery). In this step, you'll set up GitHub Actions to run your tests on every push, so broken code can never sneak through to production.

In this step, get ready to:
  • Create a GitHub Actions workflow file that runs your tests automatically.
  • Push your project to a public GitHub repository and verify CI passes.
  • Intentionally break a test to confirm the CI gate catches failures.
Create the CI workflow file

GitHub Actions workflows live in a special directory called .github/workflows/ inside your project. Each .yml file in this folder defines an automated pipeline.

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

You should see an empty directory listed with no errors.

Seeing "No such file or directory"?

Make sure you're in the devops-status-api folder. Run pwd to check your current location.

Help me fix this issue

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

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm install

      - name: Run tests
        run: npm test

What does this workflow do?

  • on: push / pull_request triggers the workflow whenever you push to main or open a pull request against it.
  • runs-on: ubuntu-latest spins up a fresh Linux virtual machine for each run.
  • actions/checkout@v4 clones your code into that VM.
  • actions/setup-node@v4 installs Node.js 22 to match your local environment.
  • npm install installs dependencies, and npm test runs the test suite you wrote earlier.
  • Save ci.yml.

Why is GitHub Actions free for this project?

GitHub Actions is free and unlimited for public repositories using standard GitHub-hosted runners. You do not need a payment method.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file at exactly .github/workflows/ci.yml.

ⓧ I'd like to double check the full code

name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm install

      - name: Run tests
        run: npm test
Push your project to GitHub

Your CI workflow is ready, but it only runs on GitHub's servers. You need to push your project to a public GitHub repository so GitHub Actions can pick it up.

  • Open github.com in your browser.
  • Click the + icon in the top-right corner and select New repository.
  • Enter devops-status-api as the repository name.
  • Select Public for the visibility.
  • Leave all initialization checkboxes unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why leave initialization unchecked?

You already have files locally. Adding a README or .gitignore on GitHub's side creates a conflict when you try to push. Starting with an empty repository avoids this.

GitHub shows a Quick Setup page with your repository URL. Copy that URL.

  • Stage and commit all your files by running these commands:
git add .
git commit -m "Add CI workflow"
  • Connect your local repository to GitHub and push by running:
git remote add origin [[REPO_URL="https://github.com/YOUR_USERNAME/devops-status-api.git"]]
git push -u origin main

What does this do?

  • git remote add origin links your local repo to the GitHub repository you just created.
  • git push -u origin main uploads all your code to the main branch and sets it as the default upstream.

Push rejected or authentication error?

If you see a permission or authentication error, make sure you're signed in to git with your GitHub credentials. You may need to set up a personal access token or SSH key for authentication.

If you see "failed to push some refs," make sure you left all initialization checkboxes unchecked when creating the repo.

Help me fix my push error

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

You should see your CI workflow running. After about 30 seconds it shows a green checkmark, meaning all tests passed.

Break and fix the test to confirm CI works

A CI gate that always passes is not very useful. You need to see it actually catch a problem. You will intentionally break a test, push it, watch the pipeline fail, then fix it.

  • In test.js, find this line inside the first test:
assert.strictEqual(response.status, 200);
  • Change 200 to 404, so the line now looks like this:
assert.strictEqual(response.status, 404);
  • Save test.js, then push the broken test by running:
git add test.js
git commit -m "Break test intentionally"
git push
  • Switch back to the Actions tab in your browser and refresh the page.

You should see a red X next to the latest workflow run. This means the CI pipeline caught the failing test and blocked it. Your CI gate is working.

  • Now fix the test. In test.js, change 404 back to 200:
assert.strictEqual(response.status, 200);
  • Save test.js and push the fix by running:
git add test.js
git commit -m "Fix test"
git push
  • Refresh the Actions tab again.

You should see the latest run pass with a green checkmark. You have now proven that your pipeline catches failures and passes when the code is correct.

Why is this important?

In a real team, this is your safety net. If a team member accidentally pushes broken code, CI catches it before it reaches production. You just simulated that exact scenario.

Your CI pipeline is live. Every push to main now triggers automatic tests. Next up, you'll connect this to Render so that passing tests trigger an automatic deployment to a live URL.

Deploy to Render

Your API is tested, containerized, and your CI pipeline gates every change. The final piece of the puzzle is getting your app live on the internet so anyone can reach it.

Render is a cloud platform that builds and hosts Docker containers directly from a GitHub repository. In this step, you'll connect your repo to Render so every passing CI build triggers a live deployment automatically.

In this step, get ready to:
  • Sign up for Render and connect your GitHub account.
  • Create a Web Service that builds from your Dockerfile.
  • Trigger the full pipeline by pushing a code change.
Sign up for Render and create a Web Service

Render's free tier requires no credit card and gives you 750 free instance hours per month. Your Docker container will be built and deployed directly from your GitHub repo.

  • Open your browser and go to render.com.
  • Click Get Started for Free and sign up with your GitHub account.
  • Render will ask you to authorize access to your GitHub repositories. Click Authorize to grant access.
  • Once redirected back to the Render Dashboard, click + New in the top-right corner.
  • Select Web Service from the dropdown menu.
  • Find your devops-status-api repository in the list and click Connect.
  • In the Language field, select Docker. This tells Render to build from your Dockerfile instead of using a native runtime.
  • Under Instance Type, choose Free.
  • Click Create Web Service.

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

What is happening during the build?

Render clones your repository, reads your Dockerfile, and builds the image using BuildKit. It then starts a container from that image and routes traffic to it at your service's public URL.

Your app reads process.env.PORT at startup. Render sets this environment variable automatically, so your container listens on the correct port without any extra configuration.

Build failed?

Check that your Dockerfile and package-lock.json are committed and pushed to GitHub. Render needs both files to build the image.

Also confirm your repository is public. Render's free tier connects to public repos by default.

Help me debug my Render build failure

Configure auto-deploy after CI checks pass

By default, Render deploys on every commit. You want it to wait for your GitHub Actions tests to pass first.

  • In the Render Dashboard, open your devops-status-api service.
  • Click Settings in the left sidebar.
  • Scroll down to Auto-Deploy.
  • Select After CI Checks Pass.
  • Click Save Changes.

How does Render know about GitHub Actions?

Render integrates with the GitHub Checks API. When you push to main, GitHub Actions reports its test results as check runs. Render waits for all checks to complete and only deploys if every check passes.

If any check fails, Render skips the deploy entirely. This means broken code never reaches your live URL.

Trigger the full pipeline with a code change

Time to test the entire workflow end-to-end. You'll make a small code change, push it, and watch GitHub Actions test it before Render deploys it.

  • In VS Code, open index.js.
  • Find the line version: '1.0.0' inside the /status route.
  • Change '1.0.0' to '1.0.1' to simulate a version bump.
  • Save index.js.
  • Also update the test to match the new version. In test.js, find this line:
assert.strictEqual(body.version, '1.0.0');
  • Change '1.0.0' to '1.0.1' so the test checks for the updated version.
  • Save test.js.
  • Commit and push the change by running these commands:
git add .
git commit -m "Bump version to 1.0.1"
git push origin main

Now watch the pipeline in action:

  • Open your repository on GitHub and click the Actions tab. You should see your CI workflow running.
  • Wait for the workflow to complete with a green checkmark.
  • Switch to the Render Dashboard. Your service's Events page should show a new deploy triggered by the passing CI checks.
  • Once the deploy finishes, click the URL at the top of your Render service page (it looks like https://your-service-name.onrender.com).
  • Append /status to the URL in your browser. You should see a JSON response with version: "1.0.1" confirming the update is live.

What just happened?

You just completed the full DevOps cycle: code change, automated tests, and automatic deployment. This is the exact workflow professional engineering teams use every day.

The free tier spins down after 15 minutes of inactivity. The first request after a spin-down takes about 30 seconds while the container restarts. This is normal for free instances.

Render not deploying after CI passes?

Confirm Auto-Deploy is set to After CI Checks Pass in your service Settings. If it was set to Off, Render ignores push events entirely.

Also check that your GitHub Actions workflow completed successfully. Render will not deploy if zero checks are detected or if any check fails.

Help me debug why Render isn't auto-deploying after my CI passes

Your containerized API is now live with a fully automated pipeline. Every future push to main will be tested and deployed without any manual intervention.

Secret mission

Add Metrics and Docker Image Tagging

Your pipeline is solid, but production APIs need observability. In this bonus mission, you'll add a /metrics endpoint that tracks request counts and response times. Then you'll create a second GitHub Actions workflow that automatically builds and publishes your Docker image to GitHub Container Registry whenever you tag a release.

Clean Up Your Resources

Clean Up Your Resources

This project uses only free-tier resources, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Why there's no cost warning

Your Render Web Service is on the free tier (750 instance hours/month, no credit card required). It automatically spins down after 15 minutes of inactivity. GitHub Actions is free and unlimited for public repositories. GitHub Container Registry is free for public images.

Resources you used:

  • Render Web Service (free tier, auto-spins down after 15 minutes of inactivity).
  • GitHub repository (devops-status-api).
  • GitHub Container Registry image on ghcr.io.
  • Local project folder (devops-status-api on your Desktop).
  • Local Docker images.

Keep everything running

No action needed. Choose this if you're still actively building or want your live URL to remain accessible.

  • Your Render service stays active on the free tier at no cost. It spins down after 15 minutes of inactivity and wakes up on the next request.
  • Your GitHub Actions workflows continue running for free on every push to your public repository.
  • Your Docker image remains publicly accessible on GitHub Container Registry at no cost.

Pause - I'll come back to this later

Free Render services already spin down automatically after 15 minutes of inactivity, so there is nothing extra to pause. If you want to fully stop deploys while you're away:

  • Open the Render Dashboard and select the checkbox next to your service.
  • Click Suspend in the action menu that appears.
  • To resume later, select the service again and click Resume from the action menu.

Your GitHub repository, Actions workflows, and Container Registry image remain intact and cost nothing while idle.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps in order:

Delete the Render Web Service
  • Open the Render Dashboard and click on your service to open it.
  • Click Settings in the left sidebar.
  • Scroll to the bottom of the Settings page and click Delete Web Service.
  • Type the service name to confirm, then click to delete.
Delete the GitHub Container Registry image
  • On GitHub, go to your repository's main page.
  • In the right sidebar, click Packages.
  • Click the name of your container package.
  • On the package's landing page, click Package settings on the right-hand side.
  • Scroll to the bottom. Under Danger Zone, click Delete this package.
  • Type the package name to confirm and click I understand the consequences, delete this package.
Delete the GitHub repository
  • On GitHub, navigate to your repository's main page.
  • Click Settings.
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Click I want to delete this repository.
  • Read the warnings and click I have read and understand these effects.
  • Type the repository name to verify, then click Delete this repository.
Delete local Docker images
  • Remove the Docker image you built by running:
docker rmi devops-status-api
Delete the local project folder
  • Remove the project directory by running:
cd ~/Desktop
rm -rf devops-status-api

Nice Work!

Nice Work!

You just built a professional-grade DevOps pipeline from scratch. Every time you push code to GitHub, your tests run automatically, and if they pass, your app deploys to a live URL without you lifting a finger.

You've learned how to:

  • Containerized a Node.js REST API with Docker using a production-ready Dockerfile and ran it locally in a portable container.
  • Set up a CI pipeline with GitHub Actions that automatically runs tests on every push to your repository.
  • Deployed automatically to a live public URL on Render that only updates when your CI checks pass.
  • Added metrics tracking and automated Docker image publishing to GitHub Container Registry on every tagged release.

Ready to quiz yourself?