What Is CI/CD?
CI/CD stands for Continuous Integration and Continuous Delivery (or Continuous Deployment). It's a set of practices that automate how code goes from a developer's machine to production.
Without CI/CD, teams manually build, test, and deploy -- leading to infrequent, risky releases. With CI/CD, every code change is automatically built, tested, and (optionally) deployed, catching problems early and shipping features fast.
Developer CI/CD Pipeline Production
push --> [Lint] -> [Test] -> [Build] -> [Deploy] --> Live
code ^
|
automated, every commit
Continuous Integration (CI)
CI is the practice of frequently merging code changes into a shared repository, where each merge triggers an automated build and test process.
The CI Feedback Loop
+----------+ +--------+ +--------+ +---------+
| Developer| | Push | | CI | | Results |
| writes | --> | commit | --> | server | --> | pass or |
| code | | | | builds | | fail |
+----------+ +--------+ | tests | +---------+
+--------+ |
v
fix if broken
(minutes, not days)
CI Principles
- Commit frequently -- at least once per day
- Build on every commit -- automated, no manual steps
- Test on every commit -- unit tests at minimum, integration tests ideally
- Fix broken builds immediately -- a broken CI pipeline blocks everyone
- Keep the build fast -- under 10 minutes is the goal
What CI Catches
- Syntax errors and type errors
- Failing unit and integration tests
- Linting violations
- Security vulnerabilities (dependency scanning)
- Build failures (missing imports, broken configs)
Continuous Delivery vs Continuous Deployment
These terms sound similar but mean different things.
Continuous Delivery:
Code -> Build -> Test -> [Manual Approval] -> Deploy to Production
Continuous Deployment:
Code -> Build -> Test -> Deploy to Production (automatic)
| Aspect | Continuous Delivery | Continuous Deployment |
|---|---|---|
| Deployment Trigger | Manual approval | Automatic |
| Human Gate | Yes (before production) | No |
| Deploy Frequency | On demand | Every passing commit |
| Risk Tolerance | Lower | Higher |
| Requires | Good tests | Excellent tests + monitoring |
| Common In | Enterprises, regulated industries | SaaS, web apps |
Most teams start with Continuous Delivery and graduate to Continuous Deployment as test coverage and monitoring mature.
Pipeline Stages
A typical CI/CD pipeline has 4-6 stages. Each stage must pass before the next one runs.
+-------+ +-------+ +-------+ +---------+ +--------+
| Lint | --> | Test | --> | Build | --> | Staging | --> | Deploy |
+-------+ +-------+ +-------+ +---------+ +--------+
| | | | |
code style unit tests compile smoke tests production
formatting integration bundle manual QA release
type check coverage artifacts (optional)
Stage 1: Lint & Format
Catch style issues before anyone even looks at the code.
# GitHub Actions example
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm run format:check
- run: npx tsc --noEmit # Type check
Stage 2: Test
Run automated tests at multiple levels.
test:
runs-on: ubuntu-latest
needs: lint # Only run if lint passes
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Stage 3: Build
Compile, bundle, and create deployable artifacts.
build:
runs-on: ubuntu-latest
needs: test # Only run if tests pass
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
Stage 4: Deploy
Ship the built artifact to production (or staging first).
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' # Only deploy main branch
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- run: |
# Deploy to production
# (Vercel, AWS, GCP, etc.)
GitHub Actions In Depth
GitHub Actions is the most popular CI/CD platform for GitHub repositories. Workflows are defined in .github/workflows/ as YAML files.
Workflow Structure
name: CI/CD Pipeline # Workflow name
on: # Triggers
push:
branches: [main]
pull_request:
branches: [main]
env: # Global environment variables
NODE_VERSION: 20
jobs: # Jobs run in parallel by default
lint: # Job name
runs-on: ubuntu-latest # Runner OS
steps: # Sequential steps
- uses: actions/checkout@v4
- run: echo "Hello"
test:
runs-on: ubuntu-latest
needs: lint # Dependency -- runs after lint
steps:
- uses: actions/checkout@v4
- run: npm test
Complete CI/CD Workflow
Here's a production-ready GitHub Actions workflow for a Next.js application:
name: CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: 20
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Stage 1: Code Quality
quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- name: Lint
run: npm run lint
- name: Type Check
run: npx tsc --noEmit
- name: Format Check
run: npx prettier --check .
# Stage 2: Unit & Integration Tests
test:
name: Tests
runs-on: ubuntu-latest
needs: quality
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
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
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- name: Run Tests
run: npm run test -- --coverage
env:
DATABASE_URL: postgres://test:testpass@localhost:5432/testdb
- name: Upload Coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/lcov.info
# Stage 3: Build
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build
path: |
.next/
public/
# Stage 4: Deploy to Staging
deploy-staging:
name: Deploy Staging
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- uses: actions/download-artifact@v4
with:
name: build
- name: Deploy to Staging
run: echo "Deploy to staging environment"
# Replace with actual deployment command
# Stage 5: Deploy to Production
deploy-production:
name: Deploy Production
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: build
- name: Deploy to Production
run: echo "Deploy to production environment"
# Replace with actual deployment command
Useful GitHub Actions Features
Matrix Strategy
Run jobs across multiple configurations:
test:
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
Caching Dependencies
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # Automatically caches node_modules
# Or manual caching for other tools
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
Secrets and Environment Variables
steps:
- name: Deploy
run: ./deploy.sh
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Never hardcode secrets. Always use GitHub's encrypted secrets.
Reusable Workflows
# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
required: true
type: string
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- run: npm test
# .github/workflows/ci.yml
name: CI
on: push
jobs:
call-tests:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: "20"
Conditional Steps
steps:
- name: Deploy to Production
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: ./deploy-prod.sh
- name: Comment PR
if: github.event_name == 'pull_request'
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: 'Build passed! Ready for review.'
})
Deployment Strategies
How you deploy matters as much as what you deploy. Different strategies offer different tradeoffs between speed, safety, and complexity.
1. Recreate (Big Bang)
Stop old version, start new version. Simple but causes downtime.
v1 [XXXXXXXXXXXXX] (stopped)
[downtime]
v2 [XXXXXXXXXXXXX] (started)
Pros: Simple, clean cut. Cons: Downtime during deployment. Use when: Downtime is acceptable, dev/staging environments.
2. Rolling Update
Gradually replace instances of the old version with the new version.
v1 [XXXX][XXXX][XXXX][XXXX] (4 instances)
v1 [XXXX][XXXX][XXXX] (3 remaining)
v2 [XXXX] (1 new)
v1 [XXXX][XXXX] (2 remaining)
v2 [XXXX][XXXX] (2 new)
...until all replaced...
v2 [XXXX][XXXX][XXXX][XXXX] (complete)
Pros: Zero downtime, gradual rollout. Cons: Both versions run simultaneously (must be compatible). Use when: Default strategy for most production deployments.
3. Blue-Green Deployment
Maintain two identical environments. Switch traffic from blue (current) to green (new) instantly.
Load Balancer
|
+---------+---------+
| |
+-------+-------+ +------+--------+
| Blue (v1) | | Green (v2) |
| ACTIVE | | IDLE/TESTING |
+---------------+ +---------------+
After switch:
+---------------+ +---------------+
| Blue (v1) | | Green (v2) |
| IDLE | | ACTIVE |
+---------------+ +---------------+
Pros: Instant rollback (just switch back), zero downtime. Cons: Double infrastructure cost, database migrations can be tricky. Use when: You need instant rollback capability.
4. Canary Deployment
Route a small percentage of traffic to the new version. Monitor. Gradually increase.
Traffic distribution over time:
100% |XXXXX| | | | |
90% |XXXXX|XXXXX| | | |
75% | |XXXXX|XXXXX| | |
50% | | |XXXXX|XXXXX| |
25% | | | |XXXXX| |
10% | |XXXXX|XXXXX|XXXXX|XXXXX|
0% | | | | |XXXXX|
+-----+-----+-----+-----+-----+
v1 v1 v1 v1 v2
v2 v2 v2 v2
start 10% 25% 50% 100%
Pros: Low risk, problems caught before full rollout, data-driven decisions. Cons: Complex routing, monitoring required, slow rollout. Use when: High-traffic applications where failures are costly.
5. Feature Flags
Deploy new code to everyone but hide it behind a flag. Enable gradually.
// Feature flag in code
if (featureFlags.isEnabled('new-checkout-flow', { userId })) {
return <NewCheckoutFlow />;
} else {
return <OldCheckoutFlow />;
}
# Feature flag configuration
flags:
new-checkout-flow:
enabled: true
rollout:
percentage: 25 # 25% of users
targeting:
- attribute: country
values: [US, CA] # US and Canada only
Pros: Deploy and release are decoupled, targeted rollouts, instant kill switch. Cons: Code complexity, flag cleanup needed, testing combinations. Use when: You want fine-grained control over who sees what.
Deployment Strategy Comparison
| Strategy | Downtime | Rollback Speed | Cost | Complexity | Risk |
|---|---|---|---|---|---|
| Recreate | Yes | Slow (redeploy) | Low | Low | High |
| Rolling | No | Medium | Low | Medium | Medium |
| Blue-Green | No | Instant | High (2x infra) | Medium | Low |
| Canary | No | Fast | Medium | High | Low |
| Feature Flags | No | Instant | Low | High | Low |
Pipeline Optimization
Speed Up CI Pipelines
Slow pipelines kill developer productivity. Target under 10 minutes.
# 1. Cache dependencies
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
# 2. Run independent jobs in parallel
jobs:
lint:
runs-on: ubuntu-latest
# ... lint steps
test:
runs-on: ubuntu-latest
# ... test steps (runs in parallel with lint)
# 3. Only run relevant tests
- name: Run affected tests
run: npx jest --changedSince=origin/main
# 4. Use smaller runner images
runs-on: ubuntu-latest # not a large custom image
# 5. Skip CI for documentation-only changes
on:
push:
paths-ignore:
- '**.md'
- 'docs/**'
Pipeline Security
# Pin action versions to specific SHA (not tags)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# Use OIDC for cloud authentication (no long-lived secrets)
permissions:
id-token: write
contents: read
# Scan dependencies for vulnerabilities
- name: Security Audit
run: npm audit --audit-level=high
# Scan Docker images
- name: Scan Image
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:latest
severity: HIGH,CRITICAL
Monitoring Deployments
Deploying is only half the battle. You need to know if the deployment succeeded.
Health Checks
# In deployment config
deploy:
steps:
- name: Deploy
run: ./deploy.sh
- name: Health Check
run: |
for i in $(seq 1 30); do
status=$(curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health)
if [ "$status" = "200" ]; then
echo "Health check passed"
exit 0
fi
echo "Attempt $i: status $status, retrying..."
sleep 10
done
echo "Health check failed after 30 attempts"
exit 1
- name: Rollback on Failure
if: failure()
run: ./rollback.sh
Post-Deploy Verification
- name: Smoke Tests
run: |
# Verify critical endpoints
curl -f https://api.example.com/health
curl -f https://api.example.com/api/v1/status
curl -f https://www.example.com
- name: Notify Team
if: always()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Deploy ${{ job.status }}: ${{ github.sha }} to production"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Common CI/CD Patterns
Monorepo Pipeline
When multiple projects live in one repository:
name: Monorepo CI
on:
push:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
api: ${{ steps.filter.outputs.api }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
frontend:
- 'packages/frontend/**'
api:
- 'packages/api/**'
build-frontend:
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd packages/frontend && npm ci && npm run build
build-api:
needs: detect-changes
if: needs.detect-changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd packages/api && npm ci && npm run build
Docker Build and Push
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
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:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Troubleshooting CI/CD
Flaky Tests
Tests that pass sometimes and fail sometimes are pipeline killers.
# Retry flaky tests (temporary fix -- find the root cause)
- name: Run Tests (with retry)
uses: nick-fields/retry@v3
with:
max_attempts: 3
timeout_minutes: 10
command: npm test
Debugging Failed Pipelines
# Add debug output
- name: Debug Info
run: |
echo "Node version: $(node -v)"
echo "npm version: $(npm -v)"
echo "Working directory: $(pwd)"
ls -la
# Enable GitHub Actions debug logging
# Set secret: ACTIONS_STEP_DEBUG = true
# SSH into runner for debugging (use tmate)
- uses: mxschmitt/action-tmate@v3
if: failure()
Key Takeaways
- CI catches bugs early by building and testing every commit automatically
- CD automates deployment -- Delivery adds a manual gate, Deployment is fully automatic
- Pipeline stages (lint, test, build, deploy) create quality gates that code must pass
- GitHub Actions uses YAML workflows triggered by events (push, PR, schedule)
- Choose deployment strategies based on risk tolerance: rolling for most cases, blue-green for instant rollback, canary for gradual validation
- Cache dependencies and run jobs in parallel to keep pipelines fast
- Never hardcode secrets -- use encrypted secrets in your CI/CD platform
- Monitor deployments with health checks and automated rollbacks
- A broken pipeline should be treated as a team-blocking emergency