~/blog/-blog-ci-cd-pipelines-explained-
blog · DevOps

CI/CD Pipelines Explained: From Code Push to Automated Deployment

What CI/CD pipelines actually do, how GitHub Actions works, how to structure stages for testing and deployment, and the mistakes that make pipelines slow or unreliable.

last updated · June 20, 2026by @vultio

CI and CD: two distinct practices often bundled together

Continuous Integration (CI) is the practice of automatically building and testing code every time it is pushed. The goal is to catch integration problems — tests that fail, type errors, broken builds — immediately, before they are merged and before they affect other developers.

Continuous Delivery (CD) extends CI by automatically deploying the verified code to one or more environments. Continuous Delivery deploys to staging automatically and requires a manual approval for production. Continuous Deployment (also CD) goes further and deploys to production automatically on every passing build. Most teams use Continuous Delivery, not Continuous Deployment.

How GitHub Actions works

GitHub Actions is the most widely used CI/CD platform for repositories hosted on GitHub. Pipelines are defined as YAML files in .github/workflows/. Each file defines one or more workflows. Workflows contain jobs; jobs contain steps; steps run shell commands or reusable actions published to the GitHub Marketplace.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest       # the runner OS (also: windows-latest, macos-latest)

    steps:
      - name: Checkout code
        uses: actions/checkout@v4   # reusable action from GitHub Marketplace

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'             # cache node_modules between runs

      - name: Install dependencies
        run: npm ci                # ci installs exactly from package-lock.json

      - name: Type check
        run: npm run typecheck

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

Secrets: how to pass credentials to pipelines

Pipelines frequently need access to API keys, database URLs, deployment tokens, and other secrets. These should never be hardcoded in workflow files or stored in the repository. GitHub Actions provides a secrets store where you add values once and reference them in workflows as environment variables that are never printed in logs.

# Add secrets in GitHub: Settings → Secrets and variables → Actions → New secret
# Then reference them in your workflow as ${{ secrets.SECRET_NAME }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to server
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DATABASE_URL: ${{ secrets.PRODUCTION_DB_URL }}
          API_TOKEN: ${{ secrets.STRIPE_API_KEY }}
        run: |
          echo "$DEPLOY_KEY" > /tmp/deploy_key
          chmod 600 /tmp/deploy_key
          ssh -i /tmp/deploy_key user@server.example.com "cd /app && git pull && npm ci && pm2 restart all"

# GitHub automatically masks secret values in log output
# Any step that would print the value shows *** instead

Multi-stage pipelines: test first, deploy second

The point of CI/CD is that deployment only happens after tests pass. GitHub Actions jobs can declare dependencies on each other with needs, creating a directed acyclic graph that prevents deployment if any upstream job fails.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm test
      - run: npm run build

  deploy-staging:
    needs: test                    # only runs if test job passes
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/develop'   # only on develop branch
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to staging
        run: ./scripts/deploy.sh staging
        env:
          DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}

  deploy-production:
    needs: [test, deploy-staging]  # both must pass
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'      # only on main branch
    environment: production        # requires manual approval in GitHub
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: ./scripts/deploy.sh production
        env:
          DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}

Caching dependencies to speed up builds

The most common source of slow pipelines is reinstalling dependencies from scratch on every run. GitHub Actions supports caching based on a key derived from the lock file — whenpackage-lock.json has not changed, the cached node_modulesis restored instead of re-downloaded.

# Method 1: Built-in cache in setup-node (simplest)
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'          # also supports 'yarn' and 'pnpm'

# Method 2: Manual cache control (more flexibility)
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

# Cache Docker layers between builds (useful for self-hosted runners)
- uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

# Typical improvement: cold build 4 minutes → warm cache 45 seconds

Matrix builds: testing across multiple versions

Matrix builds run the same job with different parameter combinations in parallel. Common uses: testing against multiple Node.js versions to verify compatibility, testing on multiple operating systems, or running a test suite in parallel shards.

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
      fail-fast: false   # continue other matrix jobs if one fails

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test

# This creates 9 parallel jobs (3 OS × 3 Node versions)
# Each runs independently and results are reported separately

Common pipeline mistakes

Running everything sequentially when jobs are independent. If linting, type checking, unit tests, and integration tests do not depend on each other, run them as separate parallel jobs. A pipeline that takes 12 minutes sequentially might complete in 4 minutes with parallelism.

Not pinning action versions. Using uses: actions/checkout@mainmeans an upstream change can break your pipeline without you changing anything. Pin to a specific version tag (@v4) or a commit SHA for stability.

Deploying on every push to every branch. Deployment jobs should be conditional on the branch (if: github.ref == 'refs/heads/main') or triggered by tags (on: push: tags: ['v*']). Accidentally deploying a work-in-progress branch to production is a common early-team mistake.

Skipping the production environment approval gate. GitHub'senvironment feature lets you require named reviewers to approve a deployment before it proceeds. For production deployments, this is a cheap safety net that prevents accidental deployments and gives the team a moment to verify the staging environment looks correct.