Build a CI/CD Pipeline with GitHub Actions
Automate testing and Docker image publishing with GitHub Actions.
Introduction
30 Second Summary
Every time you update an app on your phone, someone had to test that code, package it, and ship it to a server. In professional software teams, this entire process happens automatically through something called a pipeline.
In this project, you will build a CI/CD pipeline using GitHub Actions that automatically tests your code and packages it into a Docker container every time you push changes to your repository.
What You'll Build
A fully automated pipeline that runs tests on your Node.js application and publishes a Docker image to GitHub Container Registry on every push to the main branch.
By the end of this project, you'll have:
- A simple Node.js HTTP server with automated tests using Node.js built-in test runner.
- A Dockerfile that containerizes your application following security best practices.
- A GitHub Actions workflow with multi-job orchestration that tests your code before building and publishing the image.
- Secret Mission: Add a matrix strategy to test your app across multiple Node.js versions simultaneously.
Are there any prerequisites?
You need a GitHub account and Git installed on your Windows machine. This project walks you through installing Node.js and Docker Desktop from scratch, so no prior experience with either tool is required.
Set Up Your Environment
Before you can automate testing and deployment, you need three things on your machine: a JavaScript runtime to run your app, a container engine to package it, and a GitHub repository to host your code and pipeline configuration.
In this step, you will install Node.js and Docker Desktop, then create and clone a GitHub repository called hello-cicd.
In this step, get ready to:
- Install Node.js v24 LTS.
- Install Docker Desktop.
- Create and clone a GitHub repository.
Install Node.js v24 LTS
Node.js is the JavaScript runtime that will run your application and its tests. This project requires version 24 (the current LTS release).
- Open a terminal. Press the Windows key to open your search bar.
- Type Terminal and press Enter to open it.
- Check whether Node.js is already installed by running:
node -v
✔️ I see version 24 or higher
You are good to go. Your output looks something like v24.17.0. Skip ahead to the Docker Desktop section below.
ⓧ I see an older version
You have an older version of Node.js installed. The Windows MSI installer will replace it with version 24 automatically.
- Open https://nodejs.org/en/download in your browser.
- Download the Windows 64-bit installer (the file is named node-v24.17.0-x64.msi).
- Double-click the downloaded file and follow the installer prompts, accepting the defaults.
- After the installer finishes, close your current terminal and open a new one. This ensures the updated PATH takes effect.
- Verify the installation by running:
node -v
You should see v24.17.0 or a similar v24 version.
ⓧ Command not found
Node.js is not installed yet. You will download it from the official website.
- Open https://nodejs.org/en/download in your browser.
- Download the Windows 64-bit installer (the file is named node-v24.17.0-x64.msi).
- Double-click the downloaded file and follow the installer prompts, accepting the defaults.
- After the installer finishes, close your current terminal and open a new one. This ensures the PATH is updated.
- Verify the installation by running:
node -v
You should see v24.17.0 or a similar v24 version.
- Also confirm that npm (Node's package manager) is available by running:
npm -v
You should see a version number like 11.13.0. This confirms Node.js and npm are both ready.
Still seeing command not found?
Make sure you opened a brand new terminal window after installing. The old terminal session does not pick up path changes made by the installer.
If the issue persists, try restarting your computer and running node -v again in a fresh terminal.
help me troubleshoot why node -v is not working after installing Node.js on Windows
Install Docker Desktop
Docker Desktop provides the container engine you will use to package your application into a portable image. It uses WSL 2 (Windows Subsystem for Linux) as its backend.
- Check whether Docker is already installed by running this in your terminal:
docker --version
✔️ I see a version number
Docker is ready. You should see output like Docker version 28.x.x or similar. Skip ahead to the next section where you create your GitHub repository.
ⓧ Command not found
Docker is not installed yet. You will download Docker Desktop from the official documentation page.
- Open https://docs.docker.com/desktop/setup/install/windows-install/ in your browser.
- Click the download button at the top of the page to download Docker Desktop Installer.exe.
- Double-click Docker Desktop Installer.exe to run the installer.
- When the installer asks which mode you prefer, choose Per-user installation. This does not require administrator privileges and uses the WSL 2 backend.
- When prompted, ensure the Use WSL 2 instead of Hyper-V option is selected.
- Complete the installer and allow it to finish.
- Launch Docker Desktop from the Start menu when prompted.
What about WSL 2?
Docker Desktop needs WSL 2 enabled on your machine. If this is your first time enabling it, the installer may ask for administrator permission once. This is a one-time setup.
If the installer prompts you to enable WSL 2, follow the on-screen instructions and restart your computer if asked.
- After Docker Desktop is running, open a new terminal and verify by running:
docker --version
You should see a version number in the output, confirming Docker is installed and running.
Docker command not found after installing?
Make sure Docker Desktop is actually running. Look for the whale icon in your system tray (bottom-right of the taskbar). If it is not there, search for Docker Desktop in the Start menu and launch it.
If Docker Desktop shows a WSL-related error on startup, open a terminal and run wsl --update to update WSL, then restart Docker Desktop.
help me troubleshoot why docker --version is not working on Windows after installing Docker Desktop
Create and clone your GitHub repository
Your pipeline code and application will live in a GitHub repository. GitHub Actions (the CI/CD service) triggers automatically when you push code to this repository.
- Open https://github.com/new in your browser to create a new repository.
- Enter hello-cicd in the Repository name field.
- Select Public for the repository visibility.
- Leave all other options at their defaults (no README, no .gitignore, no license).
- Click Create repository.
Why a public repository?
GitHub Actions workflows run for free on public repositories. A public repo also means your published Docker image on GitHub Container Registry will be publicly accessible, which is perfect for a portfolio project.
- Back in your terminal, move to your Desktop by running this command:
cd ~/Desktop
- Clone your new repository by running:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/hello-cicd.git
- Move into the cloned folder by running:
cd hello-cicd
- Confirm you are inside the repository by running:
git status
You should see a message like On branch main confirming you are in the right place.
- Open the hello-cicd folder in your code editor. If you use VS Code, run:
code .
No code editor yet?
If you do not have a code editor installed, download VS Code from https://code.visualstudio.com. Any editor that can open a folder of files will work for this project.
Git clone failing?
Make sure you replaced the username in the clone URL with your actual GitHub username. The URL should look like https://github.com/yourname/hello-cicd.git.
If you see an authentication error, ensure you are signed into Git. Run git config --global user.name to check.
help me troubleshoot why git clone is failing for my hello-cicd repository
Your environment is ready. Next up, you will build a simple Node.js application with automated tests that the pipeline will run on every push.
Create the Node.js Application
Your development environment is ready. Now it is time to build the application that your CI/CD pipeline will test and deploy.
Every pipeline needs something to work with. You will create a simple Node.js HTTP server with two endpoints, then write automated tests for it. These tests are what your pipeline will run to verify your code is working before building a Docker image.
In this step, get ready to:
- Initialize a Node.js project and create an HTTP server.
- Write automated tests using Node.js built-in test runner.
- Run tests locally and push code to GitHub.
Initialize the project and create the server
Before writing any application code, you need a package.json file. This file tells Node.js about your project and defines the scripts your pipeline will use to run tests.
- Initialize a new Node.js project by running this command in your terminal (make sure you are inside the hello-cicd folder):
npm init -y
You should see output confirming a package.json file was created.
What does this command do?
The npm init -y command generates a default package.json without asking you any questions. The -y flag means "yes to all defaults."
The generated file has a placeholder test script that just prints an error. You need to replace the entire file contents with your own configuration that points to the correct entry file and test command.
- Open package.json in your code editor and replace everything inside with this:
{
"name": "hello-cicd",
"version": "1.0.0",
"description": "A simple Node.js app for learning CI/CD with GitHub Actions",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"keywords": [],
"author": "",
"license": "MIT"
}
What does this configuration do?
The scripts section defines commands you can run with npm. The start script launches your server. The test script runs node --test which automatically discovers and runs any files matching *.test.js in your project.
This test script is exactly what your CI/CD pipeline will call later to verify your code works before building a Docker image.
- Save package.json.
File not saving?
Make sure you are editing the package.json that lives inside your hello-cicd folder. If your editor opened a different directory, close it and reopen the correct folder.
help me find the right package.json file
Now you will create the actual application. Your server needs to handle two routes: a root path that returns a greeting, and a health check endpoint that monitoring tools can ping.
- Create a new file called server.js in the hello-cicd folder and paste in the following code:
const http = require("node:http");
// Set the port from environment variable or default to 3000
const PORT = process.env.PORT || 3000;
// Handle incoming HTTP requests
function handleRequest(req, res) {
// Health check endpoint for monitoring tools
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "healthy" }));
return;
}
// Default root path response
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ message: "Hello, CI/CD!" }));
}
// Create the HTTP server
const server = http.createServer(handleRequest);
// Only start listening if this file is run directly (not imported by tests)
if (require.main === module) {
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
// Export for testing
module.exports = { handleRequest, server };
What does this code do?
The handleRequest function checks the URL of each incoming request. If someone visits /health, it returns a JSON status message. For any other path, it returns a greeting.
The if (require.main === module) check means the server only starts listening when you run the file directly. When your tests import this file, it will not start a server on its own. This prevents port conflicts during testing.
The module.exports line shares handleRequest with other files so your tests can use it.
- Save server.js.
- Verify the server works by running this command:
node server.js
You should see Server running on port 3000 in your terminal.
- Press Ctrl+C to stop the server before continuing.
Seeing an error about the port?
If you see an error like EADDRINUSE, another process is already using port 3000. Close any other running servers or terminals, then try again.
help me fix a port conflict
✔️ Awesome, I've got everything!
Great. Make sure you have saved server.js and stopped the server with Ctrl+C before moving on.
ⓧ I'd like to double check the full code
const http = require("node:http");
const PORT = process.env.PORT || 3000;
function handleRequest(req, res) {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "healthy" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ message: "Hello, CI/CD!" }));
}
const server = http.createServer(handleRequest);
if (require.main === module) {
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
module.exports = { handleRequest, server };
Write automated tests for the server
Automated tests are the foundation of any CI/CD pipeline. Your pipeline needs a way to verify that code works before deploying it. Node.js v24 includes a built-in test runner, so you do not need to install any testing libraries.
- Create a new file called server.test.js in the hello-cicd folder and paste in the following code:
const { describe, it } = require("node:test");
const assert = require("node:assert");
const http = require("node:http");
const { handleRequest } = require("./server");
describe("Server", () => {
// Test that the root path returns the expected greeting
it("should return Hello, CI/CD! on root path", async () => {
const server = http.createServer(handleRequest);
await new Promise((resolve) => server.listen(0, resolve));
const port = server.address().port;
const body = await new Promise((resolve, reject) => {
http.get(`http://localhost:${port}/`, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => resolve(JSON.parse(data)));
res.on("error", reject);
});
});
assert.strictEqual(body.message, "Hello, CI/CD!");
await new Promise((resolve) => server.close(resolve));
});
// Test that the health endpoint returns a healthy status
it("should return healthy on /health path", async () => {
const server = http.createServer(handleRequest);
await new Promise((resolve) => server.listen(0, resolve));
const port = server.address().port;
const body = await new Promise((resolve, reject) => {
http.get(`http://localhost:${port}/health`, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => resolve(JSON.parse(data)));
res.on("error", reject);
});
});
assert.strictEqual(body.status, "healthy");
await new Promise((resolve) => server.close(resolve));
});
});
What does this test file do?
Each test creates its own temporary server using handleRequest (imported from server.js), starts it on a random available port with listen(0), makes an HTTP request, and checks the response matches what you expect.
The first test hits the root path and checks for Hello, CI/CD! in the response. The second test hits /health and checks for a healthy status. After each test, the server is closed to free up the port.
Why use port 0?
Passing 0 to server.listen() tells the operating system to pick any available port. This prevents conflicts if port 3000 is already in use. The test then reads the actual port from server.address().port.
- Save server.test.js.
Seeing red squiggles in your editor?
Some editors may show warnings on the node:test import if they are not configured for Node.js v24. You can safely ignore these warnings. The tests will still run correctly from the terminal.
help me understand the editor warnings
✔️ Awesome, I've got everything!
Great. Make sure server.test.js is saved before running the tests.
ⓧ I'd like to double check the full code
const { describe, it } = require("node:test");
const assert = require("node:assert");
const http = require("node:http");
const { handleRequest } = require("./server");
describe("Server", () => {
it("should return Hello, CI/CD! on root path", async () => {
const server = http.createServer(handleRequest);
await new Promise((resolve) => server.listen(0, resolve));
const port = server.address().port;
const body = await new Promise((resolve, reject) => {
http.get(`http://localhost:${port}/`, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => resolve(JSON.parse(data)));
res.on("error", reject);
});
});
assert.strictEqual(body.message, "Hello, CI/CD!");
await new Promise((resolve) => server.close(resolve));
});
it("should return healthy on /health path", async () => {
const server = http.createServer(handleRequest);
await new Promise((resolve) => server.listen(0, resolve));
const port = server.address().port;
const body = await new Promise((resolve, reject) => {
http.get(`http://localhost:${port}/health`, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => resolve(JSON.parse(data)));
res.on("error", reject);
});
});
assert.strictEqual(body.status, "healthy");
await new Promise((resolve) => server.close(resolve));
});
});
Run tests and push to GitHub
You have a server and tests. Before pushing to GitHub, you should confirm everything passes locally. This is the same command your CI/CD pipeline will run in the cloud.
- Run your tests locally by executing this command:
npm test
You should see output showing both tests passing with green checkmarks and a summary like tests 2, pass 2.
Tests failing?
If you see an error about a missing module, make sure server.js is saved in the same folder as server.test.js. The require("./server") line expects both files to be in the same directory.
If you see a syntax error, double check your server.js against the full code in the tab above. A missing comma or bracket is the most common cause.
help me debug my failing tests
Both tests pass. Now push everything to GitHub so your repository is ready for the CI/CD pipeline.
- Stage all your files by running:
git add .
- Commit your files with a descriptive message by running:
git commit -m "Add Node.js server and tests"
- Push your commit to the main branch by running:
git push origin main
You should see output confirming the push was successful with a reference to your main branch.
Push rejected or authentication error?
If git asks for credentials, make sure you are signed into GitHub in your terminal. If the push is rejected, confirm you cloned the repository (not just created the folder manually).
help me fix my git push error
Your application is built and tested. Next up, you will write the GitHub Actions workflow that automates this testing process every time you push code.
Write the CI/CD Workflow
Your application and tests are ready. Now you need to tell GitHub exactly what to do with them every time you push code.
GitHub Actions reads workflow files from your repository to run automated pipelines. In this step, you'll write a workflow with two jobs: one that runs your tests, and one that builds a Docker image only after those tests pass.
In this step, get ready to:
- Create the workflow directory and file that GitHub Actions looks for.
- Write a test job that verifies your code on every push.
- Add a build-and-push job that publishes a Docker image to GitHub Container Registry.
Create the workflow directory and file
GitHub Actions automatically detects workflow files stored in a .github/workflows/ directory at the root of your repository. You need to create this directory structure and an empty YAML file inside it.
- Create the workflow directory by running this command in your terminal (make sure you're inside the hello-cicd folder):
macOS
mkdir -p .github/workflows
Windows
mkdir .github\workflows
- Create the empty workflow file inside the new directory:
macOS
touch .github/workflows/pipeline.yml
Windows
New-Item -Path .github\workflows\pipeline.yml -ItemType File
- Confirm the file was created by running:
ls .github/workflows/
You should see pipeline.yml in the output.
Don't see pipeline.yml?
Make sure your terminal is in the hello-cicd folder, not a parent directory. Run pwd (macOS/Linux) or cd (Windows) to check your current location.
help me create the .github/workflows directory
Write the workflow header and test job
A workflow file defines when to run (triggers), what environment variables are available, and what jobs to execute. You'll start with the pipeline metadata and a job that runs your tests.
- Open .github/workflows/pipeline.yml in your code editor and paste the following code:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Run tests
run: npm test
What does this code do?
- name: gives your workflow a human-readable label that appears in the GitHub Actions tab.
- on: defines the triggers. This workflow runs on every push to main and on pull requests targeting main.
- env: sets environment variables available to all jobs. REGISTRY points to GitHub Container Registry and IMAGE_NAME uses your repository name as the image name.
- runs-on: ubuntu-latest tells GitHub to run this job on a fresh Ubuntu virtual machine (a GitHub-hosted runner).
- The three steps check out your code, install Node.js 24, and run npm test.
What is a GitHub-hosted runner?
A runner is a virtual machine that executes your workflow jobs. GitHub provides fresh Ubuntu, Windows, or macOS VMs for each run at no cost for public repositories. You don't need to set up or maintain any servers.
Add the build-and-push job
The test job verifies your code works. Now you need a second job that builds a Docker image and pushes it to GitHub Container Registry (GHCR). This job should only run after tests pass and only on pushes to main (not on pull requests).
- In pipeline.yml, add the following code directly below the last line of the test job (run: npm test):
build-and-push:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
What does this code do?
- needs: test creates a dependency. This job only starts after the test job passes. If tests fail, the build never runs.
- The if: condition gates the job to only run on pushes to main. Pull requests trigger the test job but skip the image build.
- permissions: scopes what the job can access. contents: read lets it check out code. packages: write lets it push images to GHCR.
- docker/login-action@v4 authenticates with GHCR using the built-in secrets.GITHUB_TOKEN. No extra credentials or accounts needed.
- docker/build-push-action@v7 builds the Docker image and pushes it. The image gets two tags: the commit SHA (for traceability) and latest (for convenience).
Why use needs instead of putting everything in one job?
Splitting a pipeline into separate jobs with dependencies gives you faster feedback. If a test fails, you see the failure immediately without waiting for a Docker build to start. It also means you can reuse the test job for pull requests without triggering an image push.
- Save pipeline.yml.
Seeing YAML syntax errors in your editor?
YAML is sensitive to indentation. Make sure you're using spaces (not tabs) and that the build-and-push: line is indented at the same level as test: (two spaces under jobs:).
help me fix my pipeline.yml indentation
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete pipeline.yml file. Compare it against yours:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Run tests
run: npm test
build-and-push:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- Commit and push the workflow file by running these commands:
git add .github/workflows/pipeline.yml
git commit -m "Add CI/CD workflow"
git push
What should I see?
Your push triggers the workflow immediately. If you check the Actions tab in your GitHub repository right now, you'll see the test job run and pass. The build-and-push job will fail because you haven't created a Dockerfile yet. That's expected.
Push rejected or authentication error?
If Git asks for credentials, make sure you're authenticated with GitHub. You may need to use a personal access token or set up SSH keys depending on how you cloned the repository.
help me push to GitHub
Your pipeline is defined and waiting for a Dockerfile. Next up, you'll containerize your application so the build-and-push job has something to build.
Dockerize the Application
Your CI/CD workflow already knows how to test code and build a Docker image. But the build-and-push job needs a Dockerfile to know exactly what to build.
A Dockerfile is a recipe that tells Docker how to package your application into a portable container. In this step, you'll write that recipe, test it locally, and confirm your app runs inside a container before your pipeline takes over.
In this step, get ready to:
- Create a Dockerfile for your Node.js application.
- Add a .dockerignore file to keep the image lean.
- Build and run the container locally to verify it works.
Create the Dockerfile
A Dockerfile lists instructions that Docker follows to build an image. Each instruction adds a layer, from the base operating system down to the command that starts your app.
- In your code editor, create a new file called Dockerfile in the root of your hello-cicd folder (no file extension).
- Paste the following code into it:
FROM node:24-alpine
WORKDIR /app
COPY package.json ./
COPY server.js ./
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
What does this Dockerfile do?
- FROM node:24-alpine starts from a lightweight Alpine Linux image with Node.js pre-installed. Alpine keeps your final image small (around 50 MB instead of 1 GB).
- WORKDIR /app sets the directory inside the container where all subsequent commands run.
- COPY brings only the files your app needs to run. Test files and git history stay out.
- RUN addgroup ... && adduser ... creates a non-root user. Running as root inside a container is a security risk because a compromised process would have full system access.
- EXPOSE 3000 documents which port the app listens on. It does not actually publish the port (that happens at runtime with -p).
- CMD ["node", "server.js"] is the command Docker runs when the container starts.
- Save Dockerfile.
- Confirm the file exists by running:
ls
You should see Dockerfile listed alongside server.js, server.test.js, and package.json.
Don't see Dockerfile in the output?
- Make sure the file is named exactly Dockerfile with a capital D and no file extension. Some editors silently add .txt.
- Check that you saved it in the root of your hello-cicd folder, not inside a subfolder.
Help me troubleshoot my Dockerfile location.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
FROM node:24-alpine
WORKDIR /app
COPY package.json ./
COPY server.js ./
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Create the .dockerignore file
When Docker builds an image, it sends every file in your project folder to the Docker engine as the "build context." A .dockerignore file tells Docker which files to skip, keeping builds fast and images clean.
- Create a new file called .dockerignore in the root of your hello-cicd folder.
- Paste the following into it:
.git
.github
node_modules
*.test.js
Why exclude these files?
- .git and .github contain version control history and workflow files that the running app never needs.
- node_modules is excluded because this app has zero dependencies. If it did, you would install them fresh inside the container for consistency.
- *.test.js keeps test files out of the production image. Tests run in the CI pipeline, not inside the deployed container.
- Save .dockerignore.
- Confirm the file exists by running:
ls -a
You should see .dockerignore in the listing. The -a flag shows hidden files (those starting with a dot).
Don't see .dockerignore?
- Some editors hide dotfiles by default. Make sure you used a leading dot in the filename.
- On Windows, File Explorer may not let you create files starting with a dot. Create the file from your code editor or terminal instead.
Help me create a dotfile on Windows.
✔️ Awesome, I've got everything!
Great. Your Docker build context is now properly filtered.
ⓧ I'd like to double check the full code
.git
.github
node_modules
*.test.js
Build and test the Docker image locally
Before your pipeline builds this image in the cloud, you should verify it works on your own machine. This catches errors early and saves you from debugging in CI.
- Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray.
- Build your Docker image by running this command:
docker build -t hello-cicd .
What does this command do?
- docker build reads your Dockerfile and executes each instruction to produce an image.
- -t hello-cicd tags (names) the image so you can reference it easily.
- . tells Docker to use the current directory as the build context.
You should see Docker pulling the node:24-alpine base image and running each step. The final output shows a successful build with an image ID.
Seeing a connection error?
- If you see a message about the Docker daemon not running, open Docker Desktop and wait for it to fully start (the whale icon stops animating).
- If the base image download is slow, that's normal on the first build. Subsequent builds use the cached image.
Help me fix my Docker build error.
- Run your new container by executing this command:
docker run -p 3000:3000 hello-cicd
What does -p 3000:3000 mean?
The -p flag maps a port on your machine (left side) to a port inside the container (right side). Your app listens on port 3000 inside the container, and this flag makes it reachable at localhost:3000 on your machine.
You should see Server running on port 3000 in your terminal. The container is now running in the foreground.
- Open your browser and go to http://localhost:3000.
You should see the JSON response: {"message":"Hello, CI/CD!"}. This confirms your app runs correctly inside the container.
- Return to your terminal and press Ctrl+C to stop the running container.
Not seeing the JSON response?
- If the browser shows "connection refused," make sure the terminal still shows the server running. If you accidentally closed it, run the docker run command again.
- If port 3000 is already in use, stop any other processes using that port. You may have a local Node.js server still running from earlier testing.
Help me debug my Docker container.
- Commit and push your new files by running these commands:
git add Dockerfile .dockerignore
git commit -m "Add Dockerfile and .dockerignore"
git push
You should see Git push your changes to the remote main branch.
Git push failed?
- If Git says there is nothing to commit, double check that you saved both files before running git add.
- If push is rejected, run git pull first to sync with any remote changes, then push again.
Help me fix my git push.
Your application is now containerized and proven to work locally. Next up, you'll push this commit and watch your full CI/CD pipeline run in GitHub Actions, building and publishing the image automatically.
Push and Watch the Pipeline Run
Your app is tested, Dockerized, and backed by a full CI/CD workflow. Now comes the most satisfying part of any DevOps project: watching automation do the heavy lifting for you.
Your push to the main branch triggered the workflow you wrote in pipeline.yml. GitHub Actions detected the workflow file, spun up a fresh virtual machine, and started executing your jobs. Let's go watch it happen.
In this step, get ready to:
- Navigate to your GitHub repository and open the Actions tab.
- Watch the test and build-and-push jobs execute in sequence.
- Find your published Docker image in GitHub Container Registry.
Navigate to the Actions tab
The Actions tab is where GitHub shows every workflow run triggered by pushes, pull requests, or manual dispatches. Since your push to main matched the on: push trigger in pipeline.yml, a run is already in progress (or has completed).
- Open your hello-cicd repository on GitHub.
- Under your repository name, click Actions.
- In the left sidebar, click CI/CD Pipeline (the name value from your workflow file).
What is the Actions tab?
The Actions tab lists every workflow run for your repository. Each run shows which event triggered it (push, pull request, manual), which branch it ran on, and the status of each job inside the workflow.
GitHub automatically creates a fresh virtual machine (called a GitHub-hosted runner) for each job. The runner executes your steps, then gets discarded. You never have to maintain build servers yourself.
Watch the pipeline execute
Your workflow has two jobs that run in a specific order. The test job must succeed before build-and-push begins.
- From the list of workflow runs, click the name of the most recent run to see the workflow run summary.
- Under Jobs in the left sidebar (or in the visualization graph), click test to see its logs.
- Expand the Run tests step to confirm both tests passed.
- Click build-and-push in the left sidebar to view the second job's logs.
- Expand the Build and push Docker image step to see your image being built and pushed to ghcr.io.
Once both jobs finish, you should see a green checkmark next to the workflow run name. This means your entire pipeline passed.
Why does build-and-push wait for test?
The needs: test line in your workflow tells GitHub Actions that the build-and-push job depends on the test job. If any test fails, the pipeline stops. This prevents broken code from ever being published as a Docker image.
The if condition also checks that this is a push to main (not a pull request). On pull requests, only the test job runs. This is standard practice to avoid publishing untested images from feature branches.
Pipeline showing a red X?
If the test job failed, expand the Run tests step to see which assertion failed. Double-check that server.js returns exactly Hello, CI/CD! and healthy on the respective endpoints.
If build-and-push failed on the login step, confirm your repository is public. GITHUB_TOKEN automatically has packages: write permission for public repositories when declared in the workflow.
Help me debug my failed GitHub Actions pipeline
Find your published Docker image
Your workflow pushed a Docker image to GitHub Container Registry (ghcr.io). The image is now linked to your repository and visible in the Packages section.
- Return to the main page of your hello-cicd repository.
- In the right sidebar of your repository, click Packages.
- Click hello-cicd to view the package details.
You should see your container image listed with two tags: latest and a long SHA string (the unique commit hash from your push).
What do the image tags mean?
Your workflow tags each image with both github.sha and latest. The SHA tag pins the image to a specific commit, making it easy to roll back to any previous version. The latest tag always points to the most recent successful build.
SHA tagging is a DevOps best practice. When something breaks in production, you can trace exactly which commit produced the running image and deploy a known-good version instantly.
Don't see Packages in the sidebar?
The Packages link only appears after at least one package has been successfully published to the repository. If the build-and-push job is still running, wait for it to finish and refresh the page.
If the job completed but you still don't see it, check that the workflow used secrets.GITHUB_TOKEN for authentication. Publishing with GITHUB_TOKEN automatically links the package to the workflow's repository.
Help me find my published Docker image on GitHub
Secret mission
Add a Matrix Strategy
Your pipeline tests against one Node.js version. But production apps run across multiple versions. Can you configure a matrix strategy to test Node 22 and Node 24 in parallel?
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 runs entirely on free services (GitHub Actions for public repos and GitHub Container Registry storage are both free), so there are no ongoing costs.
Resources you used:
- Local hello-cicd project folder on your Desktop.
- GitHub repository hello-cicd (code, workflow history, Actions logs).
- Docker image published to GitHub Container Registry.
- Local Docker image built during testing.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the pipeline soon.
- Your GitHub Actions pipeline will continue to run on every push to main. Since the repository is public, this costs nothing.
- Your Docker image stays published on GitHub Container Registry at no cost.
- Node.js and Docker Desktop remain installed on your machine. Docker Desktop uses background resources when running, so you can quit it from the system tray when you are not using it.
Pause - I'll come back to this later
Free up local resources while keeping your cloud assets intact for when you return.
- Quit Docker Desktop from the system tray to stop it using memory and CPU in the background.
- Your GitHub repository, Actions workflows, and Container Registry image will remain available at no cost whenever you are ready to resume.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each item below to fully clean up.
Delete the local project folder:
macOS
- Open Terminal and run:
rm -rf ~/Desktop/hello-cicd
- Confirm the folder is gone by running:
ls ~/Desktop
Windows
- Open a terminal and run:
rmdir /s /q %USERPROFILE%\Desktop\hello-cicd
- Confirm the folder is gone by running:
dir %USERPROFILE%\Desktop
Delete the local Docker image:
- Remove the locally built image by running:
docker rmi hello-cicd
Delete the Docker image from GitHub Container Registry:
- Open your GitHub profile and click the Packages tab.
- Click the hello-cicd package to open its landing page.
- On the right-hand side, click Package settings.
- Scroll to the bottom under the Danger Zone and click Delete this package.
- Type the package name to confirm, then click I understand the consequences, delete this package.
Delete the GitHub repository:
- Navigate to the main page of your hello-cicd repository on GitHub.
- Click Settings under the repository name.
- 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 hello-cicd in the text box to confirm.
- Click Delete this repository.
That's a Wrap!
That's a Wrap!
Nice work! You've just built a fully automated CI/CD pipeline that tests your code and publishes a Docker image to GitHub Container Registry on every push to main.
You've learned how to:
- Build a Node.js application with automated tests using the built-in test runner and deploy it through a hands-free pipeline.
- Create a Dockerfile following security best practices including a non-root user and a minimal Alpine base image.
- Write a GitHub Actions workflow with multi-job orchestration using needs dependencies, if conditions for branch gating, and proper permissions to push images to GitHub Container Registry.
- Secret Mission: Implemented matrix testing to validate your app across multiple Node.js versions in parallel.
Ready to quiz yourself?