How to Set Up CI/CD With GitHub Actions for Free
Build12 min read·March 7, 2026·--

How to Set Up CI/CD With GitHub Actions for Free

Automate testing, linting, and deployment with GitHub Actions — zero config servers, zero cost on the free tier. From your first push to production in under 5 minutes, fully automated.

@
@kivorablog
March 7, 2026
Share

Why CI/CD Matters for African Startups


Every time you deploy manually, you risk breaking production. Every time you skip tests, you ship bugs. For a Lagos-based SaaS with 2,000 users, a broken deployment at 11pm means lost revenue all night.


CI/CD fixes this. Every push to GitHub automatically runs your tests, checks your code quality, and deploys if everything passes. No manual steps. No "it works on my machine." No 2am fire drills.


The best part? GitHub Actions gives you 2,000 free build minutes per month. For most solo devs and small teams, that's more than enough.




The Free Stack


ToolPurposeFree LimitCost After
GitHub ActionsCI/CD workflows2,000 min/month$4/month
GitHub PackagesDocker/image registry500MB storage$0.25/GB
VercelFrontend deployment100 deployments/day$20/month
RailwayBackend deployment$5 free credits$5/month
SupabaseDatabase migrationsFree CLIFree

Total cost: $0/month




Step 1: Understand the Basics


A GitHub Action is a YAML file that defines what happens when certain events occur in your repository.


# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

This runs your tests on every push to main and every pull request. That's it. That's CI.




Step 2: Add Linting and Type Checking


# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Test
        run: npm test -- --coverage

      - name: Build check
        run: npm run build

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-node-${{ matrix.node-version }}
          path: coverage/

The npm scripts you need


{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest --passWithNoTests"
  }
}



Step 3: Auto-Deploy to Vercel


# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: []  # Add 'lint-and-test' if using a separate CI workflow
    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'

Set up Vercel secrets in GitHub:


  • Go to your repo → Settings → Secrets and variables → Actions
  • Add these secrets:
SecretWhere to Find It
VERCEL_TOKENVercel Dashboard → Settings → Tokens
VERCEL_ORG_ID.vercel/project.json or Vercel Dashboard
VERCEL_PROJECT_ID.vercel/project.json or Vercel Dashboard

Step 4: Auto-Deploy Backend to Railway

# .github/workflows/deploy-backend.yml
name: Deploy Backend

on:
  push:
    branches: [main]
    paths:
      - 'backend/**'
      - 'package.json'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Railway CLI
        run: npm install -g @railway/cli

      - name: Deploy to Railway
        run: railway up
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}

The paths filter ensures backend deploys only trigger when backend code changes. No wasted build minutes.


Step 5: Database Migrations on Deploy

# Inside deploy-backend.yml, add before the deploy step:
- name: Run Migrations
  run: |
    npm install -g supabase
    supabase db push --linked
  env:
    SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
    SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}

This ensures your database schema is always in sync with your code. No more "column does not exist" errors in production.


Step 6: Add Preview Deployments

Preview deployments create a unique URL for every pull request. Your team (or client) can review changes before merging:

# .github/workflows/preview.yml
name: Preview Deployment

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy Preview
        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 }}
        id: deploy-preview

      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: 'Preview: ${{ steps.deploy-preview.outputs.preview-url }}'
            })

Step 7: Monitoring and Notifications

Get a Slack or Discord notification on every deploy:

- name: Notify on Failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
      -H "Content-Type: application/json" \
      -d '{"content": "Deployment failed! Repository: ${{ github.repository }}, Commit: ${{ github.sha }}"}'

Build Minutes Calculator

ActivityMinutes per RunRuns per MonthTotal Minutes
CI (lint + test)3 min60 pushes180
Production deploy2 min20 deploys40
Preview deploys2 min30 PRs60
**Total****280**

You use 280 of your 2,000 free minutes. That's 14%. You'd need 7x more activity before paying a cent.

How to Minimize Build Minutes

Every minute counts when you're on the free tier. Here are practical ways to cut your CI time:

  • Cache node_modules — The cache: 'npm' option in setup-node caches your dependency installation. This alone saves 2-3 minutes per run.
  • Only run CI on relevant paths — If you only changed a Markdown file in /docs, there's no reason to run the full test suite.
  • Use matrix builds sparingly — Testing Node 18 and 20 doubles your build minutes. Only use matrix builds for libraries, not applications.
  • Fail fast — Set fail-fast: true in your matrix strategy so if one version fails, the others are cancelled.

The Complete CI/CD Workflow

Here's the full workflow file that ties everything together:

# .github/workflows/main.yml
name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - 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'

  notify:
    needs: deploy
    if: failure()
    runs-on: ubuntu-latest
    steps:
      - run: |
          curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
            -H "Content-Type: application/json" \
            -d '{"content": "Production deploy failed! Check: https://github.com/${{ github.repository }}/actions"}'

One file. Every push to main runs tests then deploys. Every pull request runs tests. Failed deploys alert you on Discord. Fully automated.


Common Mistakes

MistakeWhat HappensFix
No cache on npm installEvery build downloads all packages (3-5 min)Add `cache: 'npm'` to setup-node
Deploying on every push regardless of changesWasting build minutes on docs updatesUse `paths` filters to scope workflows
Secrets in YAML filesAnyone with repo access sees your tokensAlways use GitHub Secrets
No test coverage requirementBroken code passes CIAdd Jest coverage thresholds
Skipping CI on draft PRsUnreviewed code gets deployedRun CI on all PRs, only deploy on main
Read more on Kivora Blog

Read more on Kivora Blog

Get started →