Build a CI/CD Pipeline with GitHub Actions (Step by Step)
Build a complete CI/CD pipeline with GitHub Actions that automatically lints, tests, builds, creates Docker images, and deploys your application to Vercel, Netlify, or AWS — with matrix builds across Node.js versions and dependency caching for fast runs.
What You’ll Build
You’ll set up a production-grade CI/CD pipeline that triggers on every push and pull request: ESLint/Prettier checks, unit tests across multiple Node.js versions (18, 20, 22), dependency caching with actions/cache, Docker image build and push to Docker Hub, and automated deployment to Vercel. This same pipeline pattern deploys Doda Browser’s web app and DodaZIP’s backend.
Why Build a CI/CD Pipeline?
Manual deployment is error-prone, slow, and doesn’t scale. A CI/CD pipeline catches bugs before they reach production, ensures consistent build environments, automates repetitive tasks, and gives your team confidence to deploy frequently. Every professional project needs one — and GitHub Actions makes it accessible for free.
Prerequisites
- A GitHub account with a repository
- Basic understanding of CI/CD concepts
- Docker knowledge for containerization
- A Node.js project to deploy
Step 1: Project Setup
Create a sample project to test the pipeline:
mkdir cicd-demo
cd cicd-demo
npm init -y
npm install --save-dev eslint prettier jest @babel/preset-env// src/index.js
function add(a, b) {
return a + b;
}
function divide(a, b) {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
module.exports = { add, divide };// src/__tests__/index.test.js
const { add, divide } = require("../index");
describe("add", () => {
test("adds positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
test("adds negative numbers", () => {
expect(add(-2, -3)).toBe(-5);
});
test("adds zero", () => {
expect(add(5, 0)).toBe(5);
});
});
describe("divide", () => {
test("divides positive numbers", () => {
expect(divide(10, 2)).toBe(5);
});
test("throws on division by zero", () => {
expect(() => divide(5, 0)).toThrow("Division by zero");
});
test("divides negative numbers", () => {
expect(divide(-10, 2)).toBe(-5);
});
});Step 2: Linting and Formatting Config
// .eslintrc.json
{
"env": { "node": true, "es2022": true, "jest": true },
"parserOptions": { "ecmaVersion": 2022 },
"rules": {
"no-unused-vars": "error",
"no-undef": "error",
"semi": ["error", "always"],
"quotes": ["error", "double"],
"no-console": "warn"
}
}// .prettierrc
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}// jest.config.js
module.exports = {
testEnvironment: "node",
testMatch: ["**/__tests__/**/*.test.js"],
collectCoverage: true,
coverageDirectory: "coverage",
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};Run locally to verify:
npx eslint src/
npx prettier --check src/
npx jestExpected output:
PASS src/__tests__/index.test.js
add
✓ adds positive numbers (2 ms)
✓ adds negative numbers
✓ adds zero
divide
✓ divides positive numbers
✓ throws on division by zero (2 ms)
✓ divides negative numbers
------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
index.js | 100 | 100 | 100 | 100 |
------------|---------|----------|---------|---------|-------------------Step 3: Docker Setup
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]# Dockerfile.dev
FROM node:20-alpine
WORKDIR /app
RUN npm install -g nodemon
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["nodemon", "src/index.js"]Step 4: GitHub Actions Workflow
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: "20"
DOCKER_IMAGE: your-dockerhub-username/cicd-demo
jobs:
lint:
name: Lint & Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache npm dependencies
uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci
- run: npx eslint src/
- run: npx prettier --check src/
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
needs: lint
strategy:
matrix:
node-version: [18, 20, 22]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-
${{ runner.os }}-node-
- run: npm ci
- run: npx jest --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: ${{ matrix.node-version == env.NODE_VERSION }}
with:
name: coverage-report
path: coverage/
build:
name: Build & Dockerize
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKER_IMAGE }}
tags: |
type=sha,prefix=,suffix=,format=short
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: "--prod"
- name: Notify deployment success
run: |
echo "✅ Deployed successfully!"
echo "Commit: ${{ github.sha }}"
echo "Branch: ${{ github.ref_name }}"Step 5: Add Status Badge
Add this badge to your README.md:
Step 6: Set Up Secrets
In your GitHub repository, go to Settings → Secrets and variables → Actions and add:
DOCKER_USERNAME — your Docker Hub username
DOCKER_PASSWORD — your Docker Hub password/token
VERCEL_TOKEN — from Vercel account settings
VERCEL_ORG_ID — from Vercel project settings
VERCEL_PROJECT_ID — from Vercel project settingsArchitecture
flowchart LR
A[git push main] --> B[GitHub Actions Trigger]
B --> C{Lint Job}
C --> D[ESLint Check]
C --> E[Prettier Check]
D --> F{Test Job Matrix}
E --> F
F --> G[Node 18 Tests]
F --> H[Node 20 Tests]
F --> I[Node 22 Tests]
G --> J{Build Job}
H --> J
I --> J
J --> K[Docker Build]
K --> L[Docker Push]
L --> M{Deploy Job}
M --> N[Vercel Deploy]
N --> O[Production Live]
Common Errors
1. “npm ci” fails with “package-lock.json” mismatch
npm ci requires a package-lock.json that matches package.json. If you edit package.json manually without running npm install, the lock file is out of date. Always run npm install after changing dependencies and commit the updated lock file.
2. Docker layer cache not hitting
The default actions/cache doesn’t work well with Docker layers because each build creates different layer hashes. Use docker/build-push-action with cache-from and cache-to pointing to a local directory. For better caching, use a remote cache backend like Docker Hub’s cache or AWS S3.
3. Matrix build runs tests twice per PR
By default, the workflow triggers on both push and pull_request. For a PR from a branch in the same repo, this runs tests twice (once for the push, once for the PR). Use on: pull_request only for PR triggers, or add paths-ignore for docs-only changes.
4. Vercel deploy fails with “Project not found”
The VERCEL_PROJECT_ID and VERCEL_ORG_ID must match your Vercel project. Run npx vercel link locally to link your repo to a Vercel project. This creates a .vercel/project.json file with the IDs you need for the secrets.
Practice Questions
1. Why use needs: lint in the test job?
The needs directive creates job dependencies. If linting fails, the test job doesn’t run, saving CI minutes and giving faster feedback. The deploy job depends on build, which depends on test, which depends on lint — a sequential chain that fails fast on the first broken step.
2. What’s the advantage of matrix builds across Node.js versions? Matrix builds test your code against multiple runtime versions simultaneously. This catches compatibility issues before they reach production. If Node 22 deprecates an API you’re using, your tests will fail before your users hit the error.
3. How does dependency caching speed up CI runs?
Without caching, every CI run downloads all npm packages from scratch (~30-60 seconds). With caching, the first run saves ~/.npm to a cache keyed by the lock file hash. Subsequent runs restore the cache in ~2 seconds — a 15-30x speedup.
4. Challenge: Add a staging environment
Create a second deploy job that deploys to a staging URL on every push to the develop branch. Use a different Vercel project or a preview deployment flag (--preview). Add an environment approval gate for production deploys.
5. Challenge: Add integration tests with Docker Compose
In the test job, spin up a PostgreSQL container using Docker Compose. Write integration tests that connect to the database. Add a docker-compose.yml with your app and database services. Use docker-compose up -d in the CI step before running tests.
FAQ
Next Steps
- Learn Docker Compose for multi-service setups
- Explore Kubernetes for container orchestration
- Add Terraform for infrastructure as code
- Build the Real-Time Dashboard project for deployment monitoring
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro