As developers, we often spend hours repeatedly performing the same deployment tasks. Have you ever asked yourself how much time you could save if you could automate this process? In this article, I’ll walk you through implementing a CI/CD pipeline using GitHub Actions, demonstrated with a practical example project.
Understanding CI/CD in Modern Development
Think of CI/CD as your personal development assistant: First, it checks your code for any issues through automated tests and quality checks (that’s the CI part). Once everything looks good, it handles the deployment process automatically (that’s the CD part) — from security checks to the final deployment on Vercel or whatever deployment platform of your choice. It’s like having a code reviewer who ensures every code change is perfect before it goes live! 🚀
Let’s break down what each part does:
Continuous Integration (CI)
- Automatically runs tests when you push code
- Checks your code quality and style
- Makes sure your code works well with existing features
-
Helps catch problems early before they reach production
Continuous Deployment (CD)
Automatically deploys your tested code
Ensures your app is always ready to go live
Handles all the deployment steps for you
Gets your changes to users quickly and safely
Implementation Guide
Prerequisites
Before we begin, make sure you have the following installed on your machine:-
- Node.js(v18 or higher)
- npm(comes with Node.js)
- Git
- GitHub account
- Vercel account (free tier is fine)
- Your favorite code editor (I’m using VS Code) Project Setup
1. Install Dependencies and setting up package.json
Before we start implementing, let’s understand how our project is organized. I will guide you in creating a project that demonstrates GitHub Actions automation:
First, let’s create a repo on GitHub called automation-demo-project (or name it anything of your preference). Clone it to your local machine and cd into the project.
git clone [your-repo-url]
cd automation-demo-project
Initialize the project using npm by running this command:-
npm init -y
This command will generate a package.json file:-
{
"name": "",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {}
}
Then run this command to install the dev dependencies that we will be using:-
npm install --save-dev eslint jest nodemon supertest
- eslint: Helps maintain code quality by checking our code for potential errors and enforcing consistent coding styles.
- jest: A JavaScript testing framework that makes it easy to write and run tests.
- nodemon: Automatically restarts our application when file changes are detected.
- **supertest: **Provides a high-level abstraction for testing HTTP requests, perfect for testing our Express.js API Then, install Express by running this command:-
npm install express
- **express: **a Node.js web framework that helps to create server and API endpoints easily
After installing the dependencies, let’s create a .gitignore file in the root of our project to tell Git which files and directories (like node_modules) shouldn’t be tracked or uploaded to our repository — this helps keep our repository clean and prevents unnecessary files from being committed. Add the following:
node_modules/
.secrets
.vercel
This is what our package.json looks like so far:-
{
"name": "automation-demo-project",
"version": "1.0.0",
"description": "Demo project for GitHub Actions",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.17.1"
},
"devDependencies": {
"eslint": "^8.42.0",
"jest": "^29.7.0",
"nodemon": "^3.0.0",
"supertest": "^6.3.4"
}
}
Let’s add scripts to our package.json to manage various tasks in our project. Update the scripts section as follows:-
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"test": "jest",
"test:coverage": "jest --coverage",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}
These scripts allow us to:
- npm start: Launch the server
- npm run dev: Run in development mode with auto-reload
- npm test: Run tests
- npm run test:coverage: Generate test coverage report
- npm run lint: Check code quality
- npm run lint:fix: Automatically fix code style issues
Your complete package.json should look like this:
{
"name": "automation-demo-project",
"version": "1.0.0",
"description": "Demo project for GitHub Actions",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"test": "jest",
"test:coverage": "jest --coverage",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.17.1"
},
"devDependencies": {
"eslint": "^8.42.0",
"jest": "^29.7.0",
"nodemon": "^3.0.0",
"supertest": "^6.3.4"
}
}
This should be the initial structure after installing all the above dependencies:
automation-demo-project/
├── package.json
├── package-lock.json
├── .gitignore
└── node_modules/
Now let’s create a .eslintrc.js file in the root and add the following code:-
module.exports = {
env: {
node: true,
es2021: true,
jest: true,
commonjs: true
},
extends: ['eslint:recommended'],
parserOptions: {
ecmaVersion: 2021
},
globals: {
process: true
},
rules: {
'indent': ['error', 2],
'linebreak-style': ['error', 'unix'],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'no-unused-vars': 'warn',
'no-console': 'warn'
}
};
Then, create eslint.config.mjs, and add the following code:
import globals from 'globals';
import pluginJs from '@eslint/js';
/** @type {import('eslint').Linter.Config[]} */
export default [
{
files: ['**/*.js'],
languageOptions: {
sourceType: 'commonjs',
globals: globals.node,
},
},
{
files: ['**/*.test.js'],
languageOptions: {
globals: globals.jest,
},
},
pluginJs.configs.recommended,
];
Up to this point, this is the folder structure:
automation-demo-project/
├── .gitignore
├──eslint.config.mjs
├── package.json
├── package-lock.json
├── .eslintrc.js
└── node_modules/
Create a folder named test in the root. Create index.test.js file in the test folder, and add the following code that will be used to test the code in index.js that we will be creating next:
const request = require('supertest');
const app = require('../index');
describe('API Tests', () => {
test('GET / should return hello message', async () => {
const response = await request(app).get('/');
expect(response.body.message).toBe('Hello Github Actions!');
expect(response.statusCode).toBe(200);
});
});
Next, let’s create a file in the root called index.js, which will have a simple Express server that returns a “Hello” message — we’ll use it to demonstrate how GitHub Actions can automatically test and deploy our code whenever we make changes
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.json({ message: 'Hello Github Actions!' });
});
module.exports = app;
After creating index.js, this is our updated folder structure:
automation-demo-project/
├.gitignore
├test/
└── index.test
├package.json
├package-lock.json
├.eslintrc.js
├index.js
├eslint.config.mjs
├node_modules/
2. Implementing CI Pipeline
automation-demo-project/
├.github/
└── workflows/
└── main.yml
├test/
└── index.test
├package.json
├package-lock.json
├.eslintrc.js
├index.js
├.gitignore
├eslint.config.mjs
├node_modules/
The Continuous Integration (CI) phase automatically validates our code through testing, linting, and quality checks, then builds our application by installing dependencies and optimizing assets. Once CI passes, the Continuous Deployment (CD) phase takes over. Inside main.yml, add the code below:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
# Checks-out your repository under $GITHUB_WORKSPACE
- uses: actions/checkout@v4
# Setup Node.js environment
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm" # Caches npm dependencies
# Install dependencies
- name: Install Dependencies
run: npm ci # Uses clean install, preferred in CI environments
# Check for lint errors
- name: Run ESLint
run: npm run lint
# Run tests
- name: Run Tests
run: npm test
# Optional: Add test coverage reporting
- name: Generate Test Coverage
run: npm run test -- --coverage
3. Setting Up CD with Vercel
Next, we’ll add a deploy.yml file alongside our CI configuration in the .github/workflows directory. This file handles our automated deployment process to Vercel. Our complete workflow structure looks like this:
automation-demo-project/
├.github/
└── workflows/
└── main.yml
└── deploy.yml
├test/
└── index.test
├package.json
├package-lock.json
├.eslintrc.js
├index.js
├.gitignore
├eslint.config.mjs
├node_modules/
Continuous Deployment (CD) automates our deployment process to Vercel. Here’s how our workflow handles it inside deploy.yml:
name: Deploy to Vercel
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Check only required secrets for personal account
- name: Check Required Secrets
run: |
if [ -z "${{ secrets.VERCEL_TOKEN }}" ]; then
echo "Error: VERCEL_TOKEN is not set"
exit 1
fi
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
- name: Install Dependencies
run: npm ci
- name: Build
run: npm run build --if-present
# Simplified Vercel deployment for personal accounts
- name: Deploy to Vercel
run: |
npx vercel --token ${{ secrets.VERCEL_TOKEN }} --prod --yes
Setting up Vercel Tokens and GitHub Secrets
Commit and push all your changes to GitHub, and deploy your project on Vercel, here’s a detailed guide on how to deploy a project on Vercel.
If you check your GitHub Actions tab now, you’ll notice red ❌ failing checks — this is expected! The deployment is failing because we haven’t set up our Vercel token yet. We’ll fix this in the next step when we configure our GitHub secrets.
Now let’s create this file vercel.json in the root and add the following code:
{
"version": 2,
"builds": [
{
"src": "index.js",
"use": "@vercel/node"
}
],
"routes": [
{
"src": "/(.*)",
"dest": "index.js"
}
]
}
The vercel.json file configures how our application runs on Vercel: it specifies version 2 of Vercel’s build system, tells Vercel to use Node.js runtime for our index.js file, and sets up routing so that all incoming requests are properly directed to our Express application — think of it as a roadmap that guides Vercel on how to serve our API.
Before our deployment workflow can run successfully, we need to configure our Vercel tokens and add them to GitHub secrets. Here’s how:
1. Get Your Vercel Token:
- Go to vercel token
- Click “Create Token” — Give it a name (e.g “Vercel_Token”), scope(choose the project under where your project is running), and expiration( e.g I chose 90 days, pick one that works for your project)
- Copy the token (you won’t see it again!)
- In the root of your project create a .secrets file and paste the token you’ve created as shown below:-
VERCEL_TOKEN=paste your token here
The file structure should reflect this:
automation-demo-project/
├.github/
└── workflows/
└── main.yml
└── deploy.yml
├test/
└── index.test
├package.json
├package-lock.json
├.eslintrc.js
├index.js
├.gitignore
├.secrets
├eslint.config.mjs
├vercel.json
├node_modules/
2. Add Token to GitHub Secrets:
- Go to your GitHub repository
- Click Settings → Secrets and Variables → Actions
- Click “New repository secret”
- Name: VERCEL_TOKEN
- Value: Paste your Vercel token
Let’s understand each part of our deployment workflow:
Workflow Triggers
- name: Deploy to Vercel — Identifies our workflow in the GitHub Actions dashboard
- on: push: branches: [main] — Runs when code is pushed to the main branch
- workflow_dispatch — Allows manual workflow trigger from GitHub UI
Job Configuration
- runs-on: ubuntu-latest — Uses Ubuntu for running our deployment
- steps — List of tasks our workflow will perform
Key Steps Explained
- actions/checkout@v4 — Gets our repository code
- Check Required Secrets — Verifies Vercel token is set
- Setup Node.js — Prepares Node.js environment
- Install Dependencies — Get project dependencies
- Build — Build project if needed
- Deploy to Vercel — Deploys to Vercel using our token
This workflow ensures our code is properly prepared and securely deployed whenever we push changes.
Successful Implementation
Let’s verify our implementation step by step:
1. Local Testing
First, let’s run our tests locally to ensure everything works as expected:
npm run test && npm run lint
If the run is successful, this should be the output:
2. GitHub Actions Dashboard
Commit all your changes and push to the main branch to see it in action
Upon a successful run, you should see a green checkmark with the name of your last commit ✅ Each workflow took only seconds to complete, demonstrating the efficiency of our automated pipeline.
This workflow automatically deploys our project whenever we push code to our main branch. It checks out our code, sets up Node.js, and deploys to Vercel — all without us having to lift a finger! 🚀 The best part? Our sensitive deployment tokens are safely stored in GitHub secrets, keeping our application secure.
Conclusion
By implementing this automation pipeline, what used to take me 30 minutes of manual deployment now happens in just 2 minutes. I’d love to hear how you’re using GitHub Actions in your projects! Drop a comment below, check out my repository for the complete implementation, or reach out if you need help setting up your own automation pipeline.
I hope you found this article helpful, and that you feel confident building your automation pipeline with GitHub Actions.
You can clone this project here Automation demo repo
Top comments (1)
This was a great read and enjoyed trying it out