Skip to content
Build a CI/CD Pipeline with GitHub Actions (Step by Step)

Build a CI/CD Pipeline with GitHub Actions (Step by Step)

DodaTech Updated Jun 20, 2026 8 min read

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 jest

Expected 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:

![CI/CD](https://github.com/yourusername/cicd-demo/actions/workflows/ci-cd.yml/badge.svg)

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 settings

Architecture


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

How much does GitHub Actions cost?
Public repositories get free unlimited minutes (2000 min/month for private repos as of 2026). Matrix builds count each combination separately. With caching, most PR runs finish in 3-5 minutes. For heavy use, GitHub’s paid plans start at $0.008/min for larger runners.
Can I deploy to something other than Vercel?
Yes. The deploy job can target Netlify (use nwtgck/actions-netlify), AWS S3 (use aws-actions/configure-aws-credentials), AWS ECS (use aws-actions/amazon-ecs-deploy-task-definition), or any SSH server (use appleboy/scp-action). Replace the Vercel action with the appropriate deployment action.
How do I add manual approval for production?
Add an environment with required reviewers: deploy: environment: production. In GitHub repo settings, create a “production” environment and add required reviewers. The deploy job will pause and wait for approval before proceeding.

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