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.
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 buildSecrets: 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 *** insteadMulti-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 secondsMatrix 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 separatelyCommon 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.