Why CI/CD Is Non-Negotiable
Every manual deployment step is a risk:
- Forgetting to run tests
- Deploying to the wrong environment
- Missing a database migration
- Skipping security scans
A CI/CD pipeline automates all of this. Once configured, it's the floor of your quality standards — every change goes through the same gauntlet before touching production.
GitHub Actions Fundamentals
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
# Cancel in-progress runs when new commits are pushed
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ... jobs defined hereComplete Production Pipeline
name: Production CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: "20"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ─── Job 1: Type Check & Lint ───────────────────────────────────────────
quality:
name: Type Check & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm tsc --noEmit
- name: Lint
run: pnpm eslint . --max-warnings=0
# ─── Job 2: Tests (parallel matrix) ─────────────────────────────────────
test:
name: Tests (${{ matrix.shard }}/3)
runs-on: ubuntu-latest
needs: quality # Only run after quality passes
strategy:
matrix:
shard: [1, 2, 3] # Run tests in 3 parallel shards
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: --health-cmd "redis-cli ping" --health-interval 10s
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Run database migrations
run: pnpm db:migrate
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- name: Run tests (shard ${{ matrix.shard }}/3)
run: pnpm test --shard=${{ matrix.shard }}/3 --coverage
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
flags: shard-${{ matrix.shard }}
# ─── Job 3: Security Scan ────────────────────────────────────────────────
security:
name: Security Scan
runs-on: ubuntu-latest
needs: quality
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: "fs"
security-checks: "vuln,secret"
severity: "CRITICAL,HIGH"
exit-code: "1" # Fail on critical/high vulnerabilities
- name: Check for hardcoded secrets
uses: gitleaks/gitleaks-action@v2
# ─── Job 4: Build Docker Image ───────────────────────────────────────────
build:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: [test, security] # Must pass tests AND security
if: github.ref == 'refs/heads/main' # Only on main branch
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # GitHub Actions cache
cache-to: type=gha,mode=max
# ─── Job 5: Deploy to Production ─────────────────────────────────────────
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: build
environment:
name: production
url: https://bipinbaral.com.np
steps:
- 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 Slack on success
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "✅ Deployed to production — commit: ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}Caching for Speed
# Cache node_modules across runs — saves 2-3 minutes
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# Cache Next.js build
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}Rollback Strategy
# Emergency rollback workflow — triggered manually
name: Rollback Production
on:
workflow_dispatch:
inputs:
commit_sha:
description: "Commit SHA to rollback to"
required: true
jobs:
rollback:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.commit_sha }}
- name: Deploy previous version
run: |
vercel --prod --token=${{ secrets.VERCEL_TOKEN }}Conclusion
A well-crafted GitHub Actions pipeline is your team's immune system — automatically rejecting anything that breaks types, tests, linting, or has security vulnerabilities. The initial investment of a few hours setting up the pipeline pays back in avoided production incidents every week. Parallel sharding, intelligent caching, and environment-scoped deployments make it fast enough that it's not a bottleneck.


