CI/CD with GitHub Actions: From Zero to Production-Ready Pipeline
2026-03-15 · 7 min read
A good CI/CD pipeline is the difference between "I think this works" and "I know this works." After setting up GitHub Actions pipelines for NEXORA, CareerByte AI, and CodeMyFYP Academy, here's the pattern I've converged on.
The pipeline philosophy
Every commit to main should either be confidently deployable or fail loudly. That means automating:
- Code quality (lint + type check)
- Tests (unit + integration)
- Build verification
- Docker image build
- Deployment
Each step is a gate. Failure at any gate blocks the pipeline and notifies the developer before bad code reaches production.
The base workflow
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Lint
run: npm run lint
- name: Tests
run: npm test -- --coverage
env:
NODE_ENV: test
Caching dependencies correctly
actions/setup-node with cache: npm caches the npm store based on package-lock.json. On a cache hit, npm ci takes seconds instead of minutes. For a 200-package project, this saves 2–3 minutes per run.
The key insight: use npm ci (not npm install) in CI. npm ci is deterministic, respects lockfile exactly, and fails if the lockfile is out of sync — catching the "works on my machine" class of bugs.
Running integration tests against a real database
For tests that need PostgreSQL, use the services block to spin up a container:
jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run db:migrate
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- run: npm run test:integration
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
Docker build and push
docker:
needs: [quality, integration]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest,ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The cache-from/cache-to: type=gha uses GitHub's Actions cache for Docker layer caching. First build: slow. Every subsequent build that doesn't change layers: fast.
Secrets management
Never hardcode secrets in workflow files. Use GitHub repository secrets (Settings → Secrets → Actions) and reference them as ${{ secrets.SECRET_NAME }}.
For environment-specific config, use GitHub Environments with protection rules — require a manual approval before deploying to production.
What I'd add to every pipeline
- Dependency audit:
npm audit --audit-level=highcatches known vulnerabilities in your dependency tree - Bundle size check: For Next.js apps,
next buildoutput shows bundle sizes — add a step that fails if they exceed a threshold - Lighthouse CI: For portfolio or marketing sites, automated performance checks on every PR
The last one is particularly satisfying — it's how I catch accidental performance regressions before they reach users.