Why CI/CD Is Non-Negotiable for Modern Development
Continuous Integration and Continuous Deployment (CI/CD) is the practice of automatically running tests and deploying code on every push. Without CI/CD, quality gates live only in developers' heads — they get skipped when people are tired, rushed, or forget. With CI/CD, the pipeline enforces your standards consistently: every pull request is linted, type-checked, and tested before any reviewer even opens it. Broken code cannot reach production because the pipeline blocks it.
GitHub Actions is the most natural CI/CD choice for projects hosted on GitHub — it is deeply integrated with pull requests, commit statuses, and branch protection, and the free tier is generous (2,000 minutes/month for public repos, 500 minutes for private). This guide covers everything from your first workflow YAML to production deployment.
GitHub Actions Concepts
Understanding the four-level hierarchy makes YAML files much easier to write and debug:
- Workflow — A YAML file in
.github/workflows/. A repository can have multiple workflows (e.g.,ci.yml,deploy.yml,release.yml). - Job — A unit of work within a workflow that runs on a single runner. Jobs run in parallel by default. Use
needs:to make one job depend on another. - Step — A single task within a job. Each step either runs a shell command (
run:) or invokes an action (uses:). Steps within a job run sequentially. - Runner — The virtual machine that executes a job.
ubuntu-latestis the default and recommended runner for Node.js projects. Also available:windows-latest,macos-latest.
Triggers define when a workflow runs. Common triggers:
on:
push:
branches: [main, develop] # Run on pushes to these branches
pull_request:
branches: [main] # Run on PRs targeting main
workflow_dispatch: # Allow manual trigger from GitHub UI
schedule:
- cron: '0 9 * * 1' # Every Monday at 9am UTC
Complete Node.js CI Workflow
Here is a production-ready CI workflow for a Next.js TypeScript project:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-typecheck:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Type check
run: npx tsc --noEmit
test:
name: Tests
runs-on: ubuntu-latest
needs: lint-and-typecheck # Only run tests if lint passes
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage
env:
CI: true
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Next.js app
run: npm run build
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
Key implementation notes:
actions/checkout@v4clones your repository into the runner.actions/setup-node@v4withcache: 'npm'automatically caches~/.npmkeyed onpackage-lock.jsonhash — the single biggest speed improvement for Node.js CI.npm ci(notnpm install) performs a clean install frompackage-lock.json, fails if the lockfile is out of sync withpackage.json, and is faster for CI because it skips the lockfile resolution step.- Separating lint and tests into separate jobs provides better visibility: you see immediately whether the failure is a type error or a test failure. The
needs:key creates the dependency chain.
Environment Variables and Secrets
GitHub Secrets are encrypted values stored at the repository or organization level. They are injected into workflow runs as environment variables and are never visible in logs (GitHub masks them automatically).
Setting a secret: Repository Settings → Secrets and variables → Actions → New repository secret. Enter the name (e.g., GROQ_API_KEY) and value. The value is encrypted immediately and cannot be read back — only overwritten.
Using secrets in workflows:
steps:
- name: Build with secrets
run: npm run build
env:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
Note that NEXT_PUBLIC_ variables are embedded into the browser bundle at build time — they are not truly secret. It is still correct to store them as GitHub Secrets to avoid hardcoding URLs in the workflow YAML, but do not put genuinely sensitive credentials (API keys, service role keys) in variables with the NEXT_PUBLIC_ prefix.
For environment-specific secrets (different values for staging vs production), use GitHub Environments (Settings → Environments). An environment named "production" can have its own set of secrets and require manual approval before deployment jobs run.
Caching Dependencies for Speed
The cache: 'npm' option in actions/setup-node@v4 is the simplest caching setup. It automatically caches the npm cache directory keyed on the hash of package-lock.json. When dependencies haven't changed, the cache is restored and npm ci takes ~5 seconds instead of ~60 seconds.
For more control, use actions/cache@v4 directly:
- name: Cache node_modules
uses: actions/cache@v4
id: npm-cache
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci
Caching node_modules directly (rather than the npm cache) saves even more time — but it can occasionally cause issues if native modules need to be rebuilt for the runner OS. The cache: 'npm' shorthand in setup-node is safer for most projects.
Matrix Builds: Test Across Node.js Versions
Matrix builds run a job multiple times with different parameter values. Use them to verify your package or app works across Node.js versions:
jobs:
test:
strategy:
matrix:
node-version: ['18', '20', '22']
fail-fast: false # Don't cancel other matrix jobs if one fails
runs-on: ubuntu-latest
name: Test (Node ${{ matrix.node-version }})
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
Matrix builds are most useful for library packages that need to support multiple Node.js versions. For application deployments (Next.js apps), testing a single LTS version (currently Node 20) is sufficient. Do not add matrix builds just because you can — they consume CI minutes proportionally to the matrix size.
Deploying Next.js to Vercel: Two Options
Option A — Vercel Native Git Integration (recommended): Connect your GitHub repository to Vercel once in the Vercel dashboard. Vercel automatically deploys every push to main as a production deployment, and every pull request gets a unique preview URL. Zero workflow YAML required. Vercel's build infrastructure is optimized for Next.js (they maintain Next.js), so builds are fast and correct by default.
Option B — Vercel CLI in GitHub Actions:
jobs:
deploy:
runs-on: ubuntu-latest
needs: [lint-and-typecheck, test]
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'
Use Option B when you need to control the deployment gate precisely — for example, deploy only after all jobs pass and only from main, with production secrets injected during the workflow rather than stored in Vercel. The trade-off: more complexity to maintain, and you need to keep the VERCEL_TOKEN rotated.
Recommendation: Use Vercel native integration for most projects. Add a CI workflow (ci.yml) that runs lint/type-check/tests on PRs, and let Vercel handle deployments. Use branch protection to require the CI workflow to pass before merging — this gives you the quality gates without the complexity of a custom deploy workflow.
Branch Protection Rules: Enforcing Your Pipeline
A CI pipeline is useless if developers can merge without it passing. Branch protection rules enforce your quality gates.
Go to: Repository Settings → Branches → Add branch protection rule → Branch name pattern: main
Configure:
- Require a pull request before merging — No direct pushes to
main. All changes go through PRs. - Require status checks to pass before merging — Add your GitHub Actions jobs (
lint-and-typecheck,test,build) as required checks. PRs cannot be merged until all these pass. - Require branches to be up to date before merging — The PR must be based on the latest
mainbefore merging, preventing "works on my branch" merges that break when combined with recent changes. - Restrict who can push to matching branches — Admins only (or specific teams).
- Do not allow bypassing the above settings — Even admins must go through the PR process.
Practical Tips for Faster, Better CI
- Fail fast: Put the fastest checks first (lint runs in ~10 seconds, tests may take minutes). Use
needs:to chain jobs — skip tests if lint fails, skip builds if tests fail. - [skip ci] convention: Add
[skip ci]to commit messages that should not trigger CI (documentation updates, README changes). GitHub Actions respects this convention automatically. - Separate lint and test jobs: When both run in one job, a test failure buries lint errors. Separate jobs show exactly which gate failed without reading logs.
- Pin action versions: Use
actions/checkout@v4notactions/checkout@latest. Unpinned versions can change behavior unexpectedly on a future push. - Timeouts: Add
timeout-minutes: 10to jobs to prevent runaway jobs from consuming all your CI minutes when something hangs.