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
| Tool | Purpose | Free Limit | Cost After |
|---|---|---|---|
| GitHub Actions | CI/CD workflows | 2,000 min/month | $4/month |
| GitHub Packages | Docker/image registry | 500MB storage | $0.25/GB |
| Vercel | Frontend deployment | 100 deployments/day | $20/month |
| Railway | Backend deployment | $5 free credits | $5/month |
| Supabase | Database migrations | Free CLI | Free |
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:
| Secret | Where to Find It |
|---|---|
| VERCEL_TOKEN | Vercel 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
| Activity | Minutes per Run | Runs per Month | Total Minutes |
|---|---|---|---|
| CI (lint + test) | 3 min | 60 pushes | 180 |
| Production deploy | 2 min | 20 deploys | 40 |
| Preview deploys | 2 min | 30 PRs | 60 |
| **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: truein 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
| Mistake | What Happens | Fix |
|---|---|---|
| No cache on npm install | Every build downloads all packages (3-5 min) | Add `cache: 'npm'` to setup-node |
| Deploying on every push regardless of changes | Wasting build minutes on docs updates | Use `paths` filters to scope workflows |
| Secrets in YAML files | Anyone with repo access sees your tokens | Always use GitHub Secrets |
| No test coverage requirement | Broken code passes CI | Add Jest coverage thresholds |
| Skipping CI on draft PRs | Unreviewed code gets deployed | Run CI on all PRs, only deploy on main |

