DevOpsintermediate

CI/CD Pipelines Explained

Understand CI/CD from fundamentals to production. Learn pipeline stages, GitHub Actions workflows, deployment strategies, and real-world automation patterns.

13 min read·Published May 5, 2026
devopsci-cdgithub-actionsdeployment

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

  1. Commit frequently -- at least once per day
  2. Build on every commit -- automated, no manual steps
  3. Test on every commit -- unit tests at minimum, integration tests ideally
  4. Fix broken builds immediately -- a broken CI pipeline blocks everyone
  5. 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)
AspectContinuous DeliveryContinuous Deployment
Deployment TriggerManual approvalAutomatic
Human GateYes (before production)No
Deploy FrequencyOn demandEvery passing commit
Risk ToleranceLowerHigher
RequiresGood testsExcellent tests + monitoring
Common InEnterprises, regulated industriesSaaS, 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

StrategyDowntimeRollback SpeedCostComplexityRisk
RecreateYesSlow (redeploy)LowLowHigh
RollingNoMediumLowMediumMedium
Blue-GreenNoInstantHigh (2x infra)MediumLow
CanaryNoFastMediumHighLow
Feature FlagsNoInstantLowHighLow

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

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles