CI/CD from Zero: A Working Pipeline with GitHub Actions


CI/CD is one of those practices that feels like overhead until you work without it. Then it becomes one of those things you never want to give up: every push automatically tested, every merge automatically deployed, no more “it worked on my machine,” no more manual release processes that only one person knows how to run.

What a Good Pipeline Actually Does

Before the YAML, it’s worth being clear about what you’re building and why.

Fast feedback on broken code. The faster a developer learns their change broke something, the cheaper it is to fix. A test that runs in the developer’s IDE catches the bug in seconds. A CI check catches it in minutes. A broken deploy to staging catches it in an hour. Production catches it when users do. Each step is an order of magnitude more expensive than the previous. The primary purpose of CI is compressing this loop.

Reproducible builds. “It worked on my machine” is not a valid statement when every build runs in an identical, clean environment. CI enforces reproducibility. If it passes on every developer’s machine but fails in CI, there’s a dependency on local state that needs to be fixed. If it fails on one machine but passes in CI, you’ve found an environment issue.

Pipeline as code. The build and deployment process is version-controlled, reviewed, and changed the same way as the application code. There is no tribal knowledge about “how to deploy” that lives in one person’s head. The .github/workflows/ directory is the authoritative definition.

Deployment as a boring routine. The goal of CD is that deploying is not an event. It happens automatically, multiple times per day, with no drama. Teams that achieve this deploy more frequently, in smaller increments, with faster recovery when something goes wrong.

This article builds a real pipeline with GitHub Actions. Focus on understanding the principles each piece serves rather than copying the YAML.

What CI/CD Means

Continuous Integration (CI): every code change is automatically built and tested. If the tests pass, the change is safe to integrate. If they fail, the developer knows immediately - not when someone else pulls their code.

Continuous Delivery (CD): code that passes CI is automatically deployed to an environment (staging, production, or both). The goal is that deploying is boring - it happens all the time, automatically, with no manual steps.

The combined effect: small changes ship frequently with fast feedback. The alternative - manual testing, manual deployments, infrequent large releases - accumulates risk and slows down every team that tries it.

GitHub Actions Concepts

GitHub Actions is GitHub’s built-in CI/CD platform. It runs workflows in response to events (push, pull request, schedule, manual trigger).

Key concepts:

Workflow: a YAML file in .github/workflows/ that defines when to run and what to do.

Event: what triggers the workflow (push to main, pull request, etc.)

Job: a set of steps that run on the same machine. Jobs run in parallel by default.

Step: an individual action within a job. Either a shell command or a pre-built action.

Runner: the virtual machine that runs the job. GitHub provides Linux, macOS, and Windows runners.

Action: a reusable unit of work. Either from GitHub’s marketplace or custom.

A Basic CI Pipeline

Start simple. This workflow runs tests on every push and pull request:

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

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up 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

      - name: Run linter
        run: npm run lint

Walk through what this does:

  • on: push and on: pull_request - triggers for both direct pushes and PRs
  • runs-on: ubuntu-latest - run on GitHub’s Linux runner
  • actions/checkout@v4 - clone the repository into the runner
  • actions/setup-node@v4 with cache: 'npm' - install Node.js and cache node_modules
  • npm ci - clean install (faster and more deterministic than npm install)
  • npm test and npm run lint - your test and lint scripts

The cache: 'npm' on the setup action saves significant time by caching the npm package cache between runs.

Adding a Database for Integration Tests

Many test suites need a database. GitHub Actions supports service containers - Docker containers that run alongside your job:

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpassword
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        env:
          DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/testdb
        run: pytest

The --health-cmd pg_isready options ensure the runner waits for Postgres to be ready before running your steps.

Adding a Deployment Stage

A CD pipeline runs after tests pass and deploys to an environment:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

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

  deploy:
    needs: test  # Only runs if test job succeeds
    runs-on: ubuntu-latest
    environment: production  # Requires approval if configured

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to server
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$DEPLOY_KEY" > /tmp/deploy_key
          chmod 600 /tmp/deploy_key
          ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no \
            deploy@$DEPLOY_HOST \
            "cd /app && git pull && npm ci --omit=dev && pm2 restart app"

needs: test creates a dependency - the deploy job won’t run unless the test job succeeds. ${{ secrets.DEPLOY_SSH_KEY }} reads from GitHub repository secrets (Settings > Secrets), keeping credentials out of the workflow file.

Environment Protection Rules

For production deployments, GitHub Actions supports environment protection rules. These can require:

  • Manual approval from specific reviewers before deploying
  • Specific branches that can deploy (only main can deploy to production)
  • A wait timer before deployment proceeds

Configure these in Settings > Environments.

Caching Dependencies

Cache hits dramatically speed up pipelines. The setup actions handle this automatically with the cache parameter, but you can also manage cache explicitly:

- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

The cache key includes a hash of package-lock.json - when dependencies change, the cache key changes and npm installs fresh. Otherwise, the cached packages are restored.

Matrix Builds

Test against multiple versions of a runtime or OS:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, windows-latest]

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

This creates 6 parallel jobs (3 Node.js versions x 2 OSes). GitHub shows results for each combination.

What to Measure

A healthy CI pipeline:

  • Runs in under 10 minutes (ideally under 5). If it’s slower, developers stop waiting for it.
  • Has a high pass rate. A pipeline that frequently fails on main, not because of code bugs but because of flaky tests or infrastructure issues, loses trust.
  • Blocks merges on failure. Turn on branch protection rules that require the CI check to pass before merging.

The cost of CI - a few minutes per push - is recovered immediately the first time it catches a bug before it reaches production.



Read more