CI/CD Pipeline with GitHub Actions
Automate testing and Docker image delivery on every git push.
Introduction
30 Second Summary
Every time a developer makes a code change, someone has to test it, package it, and ship it. Doing this by hand is slow and error-prone.
In this project, you will build an automated CI/CD pipeline that tests your code and packages it into a ready-to-deploy container every time you push to GitHub.
What You'll Build
A fully automated CI/CD pipeline for a Node.js Express API that runs tests and delivers a Docker image on every push.
By the end of this project, you'll have:
- A Node.js Express API with automated tests that verify your endpoints work correctly.
- A Dockerfile that packages your application into a portable container.
- A GitHub Actions workflow with two jobs that automatically test your code and push a Docker image to Docker Hub on every push.
- Secret Mission: Optimize your pipeline with Docker layer caching and a multi-stage build for faster, leaner deployments.
Are there any prerequisites?
You should have basic Node.js knowledge and Git installed on your machine. You also need a GitHub account with at least one existing repository.
Set Up Docker Desktop and Docker Hub
Before you can automate anything, you need the tools in place. Docker lets you package your app into a container that runs identically everywhere. Docker Hub is the registry where your pipeline will push that container image.
In this step, you will install Docker Desktop, create a Docker Hub account with an access token for automation, and set up your Node.js project folder with all its dependencies.
In this step, get ready to:
- Install Docker Desktop and verify it works.
- Create a Docker Hub account and Personal Access Token.
- Initialize the Node.js project with Express, Jest, and supertest.
Install Docker Desktop
Docker Desktop is a one-click application that installs the Docker engine, CLI, and a graphical interface on your machine. It handles all the low-level virtualization so you can build and run containers locally.
- Check if Docker is already installed by running this command in a terminal:
docker --version
✔️ I see a version number
Docker is already installed on your machine. Skip ahead to verifying that the Docker daemon is running (the docker run hello-world check below the tabs).
ⓧ Command not found
Docker is not installed yet. Follow the instructions for your operating system below.
macOS
- Open https://docs.docker.com/desktop/setup/install/mac-install/ in your browser.
- Click the download button that matches your chip (Apple chip or Intel chip).
- Double-click the downloaded Docker.dmg file to open the installer.
- Drag the Docker icon into the Applications folder.
- Open Docker.app from your Applications folder to start Docker Desktop.
- Accept the Docker Subscription Service Agreement when prompted.
- Wait for the Docker engine to finish starting. You will see a whale icon in your menu bar when it is ready.
First launch takes a moment
macOS runs security checks on new applications the first time they launch. Docker Desktop may take a few minutes to start on its very first run.
Windows
- Open https://docs.docker.com/desktop/setup/install/windows-install/ in your browser.
- Click the download button to get the Docker Desktop Installer.
- Run Docker Desktop Installer.exe and choose Per-user installation (no admin required, uses WSL 2 backend).
- Complete the installation wizard and close the installer.
- Launch Docker Desktop from the Start menu.
- Wait for the Docker engine to finish starting. You will see a whale icon in your system tray when it is ready.
WSL 2 may be required
Docker Desktop on Windows uses the WSL 2 backend. If WSL 2 is not already enabled, the installer will prompt you to enable it. Follow the on-screen instructions and restart if asked.
Once Docker Desktop is running, open a new terminal and run the version check again:
docker --version
You should see a version number printed in the terminal.
Now confirm the Docker daemon is actually running by pulling and executing a test container:
- Verify Docker is running by executing this command:
docker run hello-world
- You should see a message that includes "Hello from Docker!" confirming that the installation is working correctly.
Don't see the hello message?
- Make sure Docker Desktop is fully started (whale icon visible in the menu bar or system tray). The engine takes a moment to initialize after launch.
- If you see a permissions error, close your terminal and open a new one so the updated PATH takes effect.
- On Windows, confirm WSL 2 is enabled. Open PowerShell as administrator and run wsl --status to check.
Still stuck? Help me troubleshoot Docker Desktop not starting
Create a Docker Hub account and Personal Access Token
Your CI/CD pipeline needs a way to authenticate with Docker Hub so it can push images automatically. A Personal Access Token (PAT) is more secure than a password because you can limit its permissions and revoke it independently.
- Open https://hub.docker.com in your browser and sign up for a free account (or sign in if you already have one).
- After signing in, click your avatar in the top-right corner.
- Select Account settings.
- Select Personal access tokens from the left sidebar.
- Click Generate new token.
- In the Token description field, enter github-actions-ci.
- Set the permissions to Read and Write.
- Click Generate.
- Copy the token and save it somewhere secure (a password manager or a temporary text file). You will not be able to see this token again after closing the dialog.
Why a Personal Access Token instead of a password?
A PAT is scoped to specific permissions (read, write, or delete). If it is ever compromised, you can revoke it without changing your Docker Hub password. This follows the principle of least privilege.
Save this token now
Docker Hub only shows the token value once at creation time. If you lose it, you will need to delete the old token and generate a new one. You will need this token in Step 5 when you configure GitHub Actions secrets.
Initialize the Node.js project
With Docker ready and your Hub credentials saved, it is time to create the project you will eventually containerize and deploy through the pipeline.
- Open a terminal in VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir hello-cicd
cd hello-cicd
- Initialize the project with a default package.json by running:
npm init -y
What does npm init -y do?
This command creates a package.json file with sensible defaults (project name from the folder, version 1.0.0, ISC license). The -y flag accepts all defaults so you skip the interactive prompts.
- Install Express as a production dependency by running:
npm install express@5.2.1
- Install Jest and supertest as development dependencies by running:
npm install --save-dev jest@30.4.2 supertest@7.2.2
What are these packages for?
Express is the web framework for building your API. Jest is a testing framework that runs your automated tests. Supertest lets you make HTTP requests against your Express app inside tests without starting a real server.
Now you need to update the package.json file to set the correct description, entry point, and scripts for your project.
- Open package.json in VS Code by clicking on it in the Explorer sidebar (or press Cmd+P / Ctrl+P and type package.json).
- Replace the entire contents of the file with the following:
{
"name": "hello-cicd",
"version": "1.0.0",
"description": "A CI/CD pipeline demo with GitHub Actions and Docker",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "5.2.1"
},
"devDependencies": {
"jest": "30.4.2",
"supertest": "7.2.2"
}
}
What did you change?
- description now explains what the project is for.
- main points to src/server.js which is where you will create the server entry point.
- scripts.start tells npm how to run your app.
- scripts.test tells npm to use Jest when you run npm test.
- The dependency versions are pinned exactly so your pipeline builds are reproducible.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing npm install errors?
- Make sure your terminal is inside the hello-cicd folder. Run pwd (macOS) or cd (Windows) to check your current directory.
- If you see permission errors, make sure you are not running as root. npm packages should install in the project folder, not globally.
Need help? Why is npm install failing in my project?
- Open the hello-cicd folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the hello-cicd folder, and click Open.
✔️ Awesome, I've got everything!
Great. Double check you have saved package.json and that your VS Code Explorer shows package.json, package-lock.json, and a node_modules folder.
ⓧ I'd like to double check the full code
Your package.json should look exactly like this:
{
"name": "hello-cicd",
"version": "1.0.0",
"description": "A CI/CD pipeline demo with GitHub Actions and Docker",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "5.2.1"
},
"devDependencies": {
"jest": "30.4.2",
"supertest": "7.2.2"
}
}
Your environment is ready. You have Docker running locally, a Docker Hub token saved for automation, and a Node.js project primed with Express and testing libraries. Next up, you will build the Express API and write automated tests for it.
Build the Express API with Tests
Your development environment is ready with Docker, Node.js, and a fresh project folder. Now it's time to build the application that your CI/CD pipeline will test and deliver.
You'll create a simple Express API with two endpoints, then write automated tests for them. These tests are the foundation of your pipeline. They give GitHub Actions something to verify before building your container image.
In this step, get ready to:
- Create the Express API with two endpoints.
- Write automated tests using Jest and supertest.
- Run the tests locally to confirm they pass.
Create the Express application
Your API needs two parts: the application logic (routes and responses) and a server file that starts listening for requests. Separating them makes the app testable without actually starting a server.
- In VS Code's Explorer sidebar, right-click in the file list and select New File.
- Type src/app.js and press Enter. This creates both the src folder and the file.
- Paste the following code into src/app.js:
const express = require('express');
const app = express();
// Define the root endpoint
app.get('/', (req, res) => {
res.json({ message: 'Hello from the CI/CD pipeline!' });
});
// Define the health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
What does this code do?
- require('express') imports the Express framework you installed in Step 1.
- app.get('/', ...) defines a route that responds with a JSON welcome message when someone visits the root URL.
- app.get('/health', ...) defines a health check endpoint that returns { status: 'ok' }. Pipelines often use health checks to verify an app is running.
- module.exports = app exports the app so other files (the server and your tests) can import it.
- Save src/app.js.
- You should see app.js listed under the src folder in VS Code's Explorer sidebar.
Don't see the src folder?
Make sure you typed src/app.js (with the forward slash) when creating the file. If you accidentally created app.js in the project root, delete it and try again with the full path.
help me fix my file structure
Create the server file
The server file imports your app and starts listening on a port. Keeping it separate from the app logic means your tests can import the app without accidentally starting a server.
- Create another new file in VS Code by right-clicking in the Explorer sidebar and selecting New File.
- Type src/server.js and press Enter.
- Paste the following code into src/server.js:
const app = require('./app');
// Use the PORT environment variable, or default to 3000
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
- require('./app') imports the Express app you just defined in app.js.
- process.env.PORT || 3000 reads the port from an environment variable. If none is set, it falls back to 3000. This pattern lets deployment platforms assign their own port.
- app.listen(PORT, ...) starts the server and logs a message confirming it's running.
- Save src/server.js.
- You should now see both app.js and server.js listed under the src folder in the sidebar.
Seeing a red underline or error in VS Code?
If VS Code shows a warning on the require line, it's likely a linting suggestion about ES modules. You can safely ignore it. Your code uses CommonJS (require/module.exports) which Node.js supports natively.
help me understand this VS Code warning
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on to the tests.
ⓧ I'd like to double check the full code
Here is the full src/app.js:
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.json({ message: 'Hello from the CI/CD pipeline!' });
});
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
Here is the full src/server.js:
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Write automated tests
Automated tests let your pipeline catch bugs before they reach production. You'll use Jest as the test runner and supertest to make HTTP requests to your app without starting the server.
- Create a new file by right-clicking in the Explorer sidebar and selecting New File.
- Type src/app.test.js and press Enter.
- Paste the following test code into src/app.test.js:
const request = require('supertest');
const app = require('./app');
describe('API Endpoints', () => {
// Test the root endpoint
test('GET / returns welcome message', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Hello from the CI/CD pipeline!');
});
// Test the health check endpoint
test('GET /health returns status ok', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
const healthResponse = await request(app).get('/health');
expect(healthResponse.status).toBe(200);
expect(healthResponse.body.status).toBe('ok');
});
});
What does this code do?
- require('supertest') imports a library that can send HTTP requests to your Express app without needing a running server.
- describe('API Endpoints', ...) groups related tests together under a label.
- request(app).get('/') sends a GET request to your app's root endpoint and captures the response.
- expect(response.status).toBe(200) asserts the response has a 200 (success) status code. If the assertion fails, the test fails.
- expect(response.body.message).toBe(...) checks the JSON body matches the exact string your route returns.
- Save src/app.test.js.
- You should now see three files in the src folder: app.js, app.test.js, and server.js.
Don't see all three files?
Check that each file is inside the src folder, not in the project root. Jest auto-discovers files matching *.test.js, but app.test.js must be in the same folder as app.js because it imports ./app.
help me fix my test file location
✔️ Awesome, I've got everything!
Great. Make sure src/app.test.js is saved before running the tests.
ⓧ I'd like to double check the full code
Here is the full src/app.test.js:
const request = require('supertest');
const app = require('./app');
describe('API Endpoints', () => {
test('GET / returns welcome message', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Hello from the CI/CD pipeline!');
});
test('GET /health returns status ok', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
const healthResponse = await request(app).get('/health');
expect(healthResponse.status).toBe(200);
expect(healthResponse.body.status).toBe('ok');
});
});
Run tests locally
Before pushing code to GitHub, you should always run tests locally first. This catches issues early and saves you from waiting for a remote pipeline to fail.
- Run your tests by entering this command in the VS Code terminal:
npm test
- Wait for Jest to finish. You should see output showing both tests passed, including the text "2 passed" at the bottom.
What should I see?
Jest prints a summary showing the test suite name (app.test.js), each test name with a checkmark, and a final line confirming Tests: 2 passed, 2 total. Both tests running against your app without starting the server is the magic of supertest.
Tests failing?
- If you see "Cannot find module './app'", make sure app.test.js is inside the src folder alongside app.js.
- If you see "Cannot find module 'supertest'", run npm install in your terminal to make sure dependencies are installed.
- If the JSON assertion fails, double check that the message in app.js is exactly Hello from the CI/CD pipeline! with no extra spaces.
help me debug my failing tests
Your Express API is built and verified with passing tests. Next up, you'll containerize this application with Docker so it can run anywhere.
Containerize with Docker
Your Express API is built and the tests pass. But right now, the app only runs on your machine with your specific Node.js version and dependencies installed.
To make your app portable and production-ready, you will package it inside a Docker container. A container bundles your code, runtime, and dependencies into a single image that runs identically anywhere.
In this step, get ready to:
- Create a Dockerfile to package your API into a container image.
- Create a .dockerignore file to keep unnecessary files out of the image.
- Build and run the container locally to verify everything works.
Create the Dockerfile
A Dockerfile is a set of instructions that tells Docker how to build your image layer by layer. Each instruction creates a new layer, starting from a base image and adding your application on top.
- In VS Code, create a new file called Dockerfile in the root of your hello-cicd folder (the same level as package.json).
- Paste the following code into Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/server.js"]
What does this Dockerfile do?
- FROM node:22-alpine starts from a lightweight Node.js 22 image based on Alpine Linux. Alpine images are much smaller than full images.
- WORKDIR /app sets the working directory inside the container. All following commands run from this folder.
- COPY package.json package-lock.json ./ copies your dependency files first. This lets Docker cache the install layer separately from your source code.
- RUN npm ci --omit=dev installs only production dependencies (skipping jest and supertest) for a smaller, more secure image.
- COPY src/ ./src/ copies your application code into the container after dependencies are installed.
- CMD ["node", "src/server.js"] tells Docker what command to run when the container starts.
Why copy package files before source code?
Docker caches each layer. If your source code changes but your dependencies don't, Docker reuses the cached npm ci layer instead of reinstalling everything. This makes rebuilds much faster.
- Save Dockerfile.
Seeing a red underline in VS Code?
VS Code may not recognize Dockerfile syntax by default. This does not affect your build. The file works as long as it is named exactly Dockerfile with no file extension.
Why isn't my Dockerfile being recognized by VS Code?
✔️ Awesome, I've got everything!
Great. Double check you have saved your Dockerfile.
ⓧ I'd like to double check the full code
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/server.js"]
Create a .dockerignore file
A .dockerignore file tells Docker which files and folders to exclude when copying your project into the image. This keeps the image lean and avoids leaking files that don't belong in production.
- Create a new file called .dockerignore in the root of your hello-cicd folder.
- Paste the following into .dockerignore:
node_modules
.git
.github
Dockerfile
.dockerignore
README.md
Why exclude these files?
- node_modules is excluded because npm ci installs fresh dependencies inside the container. Including your local copy would add bloat and potentially wrong platform binaries.
- .git and .github contain version control history and workflow files that the running app does not need.
- Dockerfile and .dockerignore are build instructions, not application files.
- Save .dockerignore.
- Confirm both files exist by running:
ls
You should see Dockerfile and .dockerignore listed alongside package.json and src/.
Don't see .dockerignore in the listing?
Files starting with a dot are hidden by default on some systems. Run ls -a to show all files including hidden ones. Make sure the file is named exactly .dockerignore with the leading dot.
Help me find my .dockerignore file
✔️ Awesome, I've got everything!
Great. Double check you have saved your .dockerignore.
ⓧ I'd like to double check the full code
node_modules
.git
.github
Dockerfile
.dockerignore
README.md
Build and run the container locally
With the Dockerfile in place, you can now build a container image and run it to verify your API works inside Docker.
- Build your Docker image by running this command:
docker build -t hello-cicd .
What does this command do?
- docker build reads your Dockerfile and creates a container 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, which is where it looks for your Dockerfile and application files.
The build takes 30-60 seconds the first time as Docker downloads the node:22-alpine base image and installs dependencies.
Build failed?
Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.
If you see an error about package-lock.json not being found, run npm install once in your project folder. This generates the lock file that npm ci requires.
Help me fix my Docker build error
- Run your container by executing this command:
docker run -p 3000:3000 hello-cicd
What does this command do?
docker run starts a new container from your image. The -p 3000:3000 flag maps port 3000 on your machine to port 3000 inside the container, so you can access the API from your browser.
- Open your browser and visit http://localhost:3000.
You should see the JSON response: {"message":"Hello from the CI/CD pipeline!"}.
- Return to your terminal and press Ctrl+C to stop the container.
Don't see the JSON response in the browser?
Make sure the terminal shows Server running on port 3000 after running docker run. If you see an error about the port being in use, stop any other process on port 3000 (such as a previous node src/server.js session).
Help me debug my container
Your API is now running inside a Docker container. Next up, you will create a GitHub Actions workflow that automates testing and pushes this image to Docker Hub every time you commit code.
Create the GitHub Actions Workflow
Your Docker container runs perfectly on your machine. The next challenge is making sure it gets built and tested automatically every time you push code.
In this step, you will write a GitHub Actions workflow file that defines your CI/CD pipeline. This file tells GitHub exactly what to do when code arrives: run your tests first, then build and push a Docker image.
In this step, get ready to:
- Create the workflow directory and YAML file.
- Define a test job that runs your Jest tests on every push.
- Define a build-and-push job that delivers your Docker image to Docker Hub.
Create the workflow file
GitHub Actions looks for workflow definitions in a specific directory inside your repository. The path must be exactly .github/workflows/ for GitHub to detect your pipeline.
- Create the workflow directory by running this command in your terminal:
mkdir -p .github/workflows
- Confirm the directory exists by running:
ls .github/workflows
You should see an empty directory listed with no errors.
Seeing an error?
Make sure your terminal is in your hello-cicd project folder. Run ls to check for package.json in the current directory.
Help me navigate to my project folder in the terminal.
- Create a new file called ci.yml inside .github/workflows/ in VS Code.
- Paste the following workflow header into ci.yml:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
What does this code do?
- name: gives your workflow a human-readable label that shows up in the GitHub Actions tab.
- on: defines the events that trigger the pipeline. Here, the pipeline runs on every push to main and every pull request targeting main.
You will not see a change yet. The next chunk adds the jobs that make this pipeline do real work.
Define the test job
A workflow contains one or more jobs. Each job runs on a fresh virtual machine. Your first job will check out your code, install dependencies, and run your Jest tests.
- Add the following code below the trigger block in ci.yml:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
What does this code do?
- jobs: is the top-level key that holds all your pipeline jobs.
- test: defines a job named "test" that runs on a fresh ubuntu-latest virtual machine.
- actions/checkout@v6 clones your repository code into the runner so subsequent steps can access your files.
- actions/setup-node@v6 installs Node.js 22 and enables npm caching so repeat runs install dependencies faster.
- npm ci installs exact dependency versions from package-lock.json for reproducible builds.
- npm test runs your Jest test suite. If any test fails, the job fails and blocks downstream jobs.
You will not see a change yet. The build job in the next chunk uses this test job as a quality gate.
Define the build-and-push job
The second job handles the delivery side of your pipeline. It builds a Docker image from your code and pushes it to Docker Hub. It only runs after the test job passes, so broken code never gets shipped.
- Add the following code below the test job (still inside the jobs: block) in ci.yml:
build_and_push:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v6
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ vars.DOCKER_USERNAME }}/hello-cicd:latest
${{ vars.DOCKER_USERNAME }}/hello-cicd:sha-${{ github.sha }}
What does this code do?
- needs: test creates a dependency. This job waits for the test job to pass before starting.
- if: github.event_name == 'push' ensures this job only runs on direct pushes to main, not on pull requests. Pull requests only run tests.
- docker/login-action@v4 authenticates with Docker Hub using your stored username variable and secret token.
- docker/setup-buildx-action@v4 installs Docker Buildx, an extended builder that supports advanced features like caching.
- docker/build-push-action@v7 builds your image from the Dockerfile and pushes it to Docker Hub with two tags: latest and a unique commit SHA tag for traceability.
Why two tags?
The latest tag always points to the most recent build, making it easy to pull the newest version. The sha- tag ties each image to a specific commit, so you can trace exactly which code produced any given image.
- Save .github/workflows/ci.yml.
- Confirm the file exists by running:
ls .github/workflows/ci.yml
You should see .github/workflows/ci.yml listed in the output.
File not found?
Double-check that you created the nested directory structure correctly. The path must be .github/workflows/ci.yml with the dot at the start of .github. Folders starting with a dot are hidden by default in file browsers.
Also check for typos in the filename. It must be ci.yml (not ci.yaml or CI.yml).
Help me fix my workflow file path.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete ci.yml file. Make sure your version matches exactly, including indentation:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
build_and_push:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v6
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ vars.DOCKER_USERNAME }}/hello-cicd:latest
${{ vars.DOCKER_USERNAME }}/hello-cicd:sha-${{ github.sha }}
Your workflow is written and ready. In the next step, you will push this code to GitHub, configure your secrets, and watch the pipeline run automatically.
Push to GitHub and Watch the Pipeline
In the previous steps, you built an Express API, wrote tests, containerized the app, and defined a GitHub Actions workflow. All the pieces are in place locally.
Now it is time to connect everything to the cloud. You will push your code to GitHub, configure your Docker Hub credentials as repository secrets, and watch the pipeline execute automatically from start to finish.
In this step, get ready to:
- Create a GitHub repository and configure secrets.
- Push your code and trigger the pipeline.
- Verify the Docker image on Docker Hub.
Create the GitHub repository and configure secrets
Your workflow file references two credentials: a DOCKER_USERNAME variable and a DOCKERHUB_TOKEN secret. You need to create the GitHub repository first, then store these values so the pipeline can authenticate with Docker Hub.
- Open your browser and go to github.com/new.
- Type hello-cicd as the repository name.
- Select Public for the repository visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why no README or .gitignore?
You already have all your project files locally. Initializing the repo with a README would create a commit on GitHub that conflicts with your local commit history. Starting empty keeps the push clean.
GitHub shows a Quick Setup page after creation. Keep this page open because you will need the repository URL in a moment.
Next, configure the credentials your workflow needs to push images to Docker Hub.
- In your new repository on GitHub, click Settings.
- In the left sidebar under the Security section, click Secrets and variables, then click Actions.
- Click the Variables tab.
- Click New repository variable.
- In the Name field, enter DOCKER_USERNAME.
- In the Value field, enter your Docker Hub username.
- Click Add variable.
Now add the secret for your Docker Hub Personal Access Token.
- Click the Secrets tab (next to the Variables tab you were just on).
- Click New repository secret.
- In the Name field, enter DOCKERHUB_TOKEN.
- In the Secret field, paste the Personal Access Token you saved in Step 1.
- Click Add secret.
Why a variable and a secret?
Your Docker Hub username is not sensitive, so it goes in a repository variable (accessible via vars.DOCKER_USERNAME in the workflow). The PAT is sensitive, so it goes in a repository secret (accessible via secrets.DOCKERHUB_TOKEN). GitHub encrypts secrets and never displays them after creation.
Don't see the Settings tab?
Only repository owners can access Settings. Make sure you are signed in with the account that created the repository. If you still cannot see it, click the dropdown menu (three dots) next to the repository tabs and look for Settings there.
Help me find repository settings on GitHub.
Push your code and trigger the pipeline
With the repository created and credentials configured, you can push your local project. The push to main triggers the workflow automatically.
- Initialize git and push your code by running these commands in the VS Code terminal:
git init
git add .
git commit -m "Initial commit: Express API with CI/CD pipeline"
git branch -M main
git remote add origin [[GITHUB_REPO_URL="https://github.com/your-username/hello-cicd.git"]]
git push -u origin main
What do these commands do?
- git init creates a new Git repository in your project folder.
- git add . stages all your files for the first commit.
- git branch -M main renames the default branch to main (matching what your workflow listens for).
- git remote add origin connects your local repo to the GitHub repository you just created.
- git push -u origin main uploads your code and triggers the CI/CD workflow.
Seeing an authentication error?
GitHub no longer accepts passwords for git push. If prompted for credentials, use a GitHub Personal Access Token (not your GitHub password) or set up SSH authentication.
Help me authenticate git push to GitHub.
- In your browser, go to your hello-cicd repository on GitHub.
- Click the Actions tab at the top of the repository.
You should see your workflow running. The test job starts first. Once it passes, the build_and_push job begins automatically because of the needs: test dependency you defined in the workflow.
What is happening in the Actions tab?
GitHub detected your push to main and found the .github/workflows/ci.yml file. It automatically spun up two jobs on cloud-hosted runners. The test job installs dependencies and runs npm test. When tests pass, the build job logs into Docker Hub, builds your image, and pushes it.
This is the core of CI/CD: code goes in, tested artifacts come out, with zero manual steps.
Verify the results
The real proof that your pipeline works is seeing green checkmarks in GitHub and a new image on Docker Hub.
- Wait for both jobs to finish in the Actions tab (this usually takes 1-2 minutes).
- Confirm both the test and build_and_push jobs show green checkmarks.
- Open hub.docker.com and sign in to your Docker Hub account.
- Click Repositories in the top navigation. You should see hello-cicd listed with a recent push timestamp.
Finally, verify you can pull and run the image that your pipeline built.
- Pull the image from Docker Hub by running this command:
docker pull [[DOCKERHUB_USER="your-docker-hub-username"]]/hello-cicd:latest
- Run the pulled image by running this command:
docker run -p 3000:3000 [[DOCKERHUB_USER="your-docker-hub-username"]]/hello-cicd:latest
- Open http://localhost:3000 in your browser.
You should see the JSON response: {"message":"Hello from the CI/CD pipeline!"}. This image was built entirely by your pipeline in the cloud, not on your local machine.
- Stop the container by pressing Ctrl+C in the terminal.
Seeing a red X on one of the jobs?
Click the failed job name in the Actions tab to see the log output. Common causes:
- The DOCKER_USERNAME variable or DOCKERHUB_TOKEN secret was misspelled or not created. Double-check the names match exactly.
- The Docker Hub PAT does not have Read and Write permissions. Regenerate the token with the correct scope.
- A test failed. Check the test job logs for the specific assertion error.
Help me debug my failed GitHub Actions workflow.
Secret mission
Add Docker Layer Caching and Multi-Stage Build
Your pipeline works, but every build starts from scratch and ships dev dependencies in the final image. In production, that means slower deployments and unnecessarily large containers. Can you optimize both?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses GitHub Actions (free for public repos) and Docker Hub (free for public repos), so there are no ongoing costs.
Resources you used:
- Local project folder (hello-cicd on your Desktop).
- GitHub repository (hello-cicd).
- Docker Hub image (your-username/hello-cicd).
- Docker Hub Personal Access Token (github-actions-ci).
- Docker Desktop (installed application).
Keep everything running
No action needed. Choose this if you're still actively building or plan to add more features to your pipeline soon.
- Your GitHub Actions workflow will continue to run on every push to main.
- Your Docker Hub image will remain available for pulling.
- Docker Desktop will continue running in the background and using system memory.
Pause - I'll come back to this later
Free up system memory by stopping Docker Desktop. Your code and cloud resources stay intact for when you return.
- Right-click the Docker Desktop icon in your system tray and select Quit Docker Desktop.
- Your GitHub repository, workflow, and Docker Hub image remain untouched.
- When you return, open Docker Desktop again to resume.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each instruction below to clean up everything.
Delete the local project folder
- Delete your local project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/hello-cicd
Windows
Remove-Item -Recurse -Force "$env:USERPROFILE\Desktop\hello-cicd"
Delete the GitHub repository
- Go to your hello-cicd repository on GitHub.
- Click Settings in the top navigation bar.
- Scroll down to the Danger Zone section at the bottom of the page.
- Click Delete this repository and follow the confirmation prompts.
Delete the Docker Hub image
- Go to hub.docker.com and sign in.
- Navigate to your hello-cicd repository.
- Click the Settings tab.
- Click Delete repository, enter the repository name to confirm, and click Delete Repository Forever.
Revoke the Docker Hub Personal Access Token
- On Docker Hub, click your avatar (top-right) then Account settings.
- Select Personal access tokens.
- Find the github-actions-ci token, click the actions menu on the right, and select Delete.
Uninstall Docker Desktop
- Open Windows Settings by pressing the Windows key and typing Settings.
- Select Apps, then Apps & features.
- Find Docker Desktop in the list and click Uninstall.
Nice Work!
Nice Work!
You just built a fully automated CI/CD pipeline from scratch. Every time you push code to GitHub, your pipeline tests it, builds a Docker image, and delivers it to Docker Hub with zero manual intervention.
You've learned how to:
- Build a Node.js Express API with automated tests using Jest and supertest, then containerize it with a Dockerfile.
- Create a GitHub Actions workflow with multi-job dependencies that automatically runs tests and builds a Docker image on every push to main.
- Configure repository secrets and variables for secure authentication, and publish container images to Docker Hub automatically.
- Secret Mission: Optimized the pipeline with Docker layer caching and a multi-stage Dockerfile for faster builds and smaller images.
Ready to quiz yourself?