CI/CD doesn't need to be complex to be professional. A well-crafted pipeline with GitHub Actions can run lint, tests, build, and deploy in a single YAML configuration that any engineer on the team can understand and maintain.

This article builds a real pipeline from scratch, explains every decision, and shows the patterns that avoid the most common production problems.

Basic workflow structure

A GitHub Actions workflow is a YAML file in .github/workflows/. The structure is simple: when to run, in what environment, with what steps.

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

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

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test --coverage
      - run: pnpm build

That's the minimum pipeline. Simple, but already catches most problems before reaching production.

Dependency cache: the detail that changes speed

Without cache, each run installs all dependencies from scratch. That can be 2-3 minutes. With cache, installation of unchanged dependencies is skipped.

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'pnpm'  # ← lockfile hash as cache key

For npm, use cache: 'npm'. For yarn, cache: 'yarn'. The cache uses the lockfile hash as a key. When the lockfile changes, the cache is invalidated.

Separating CI from CD: the deploy job

CI (continuous integration) runs on every PR. CD (continuous delivery) runs only when code enters production. Separate into distinct jobs with explicit dependency:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      # ... lint, test, build

  deploy:
    needs: ci          # only runs if CI passes
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'  # only on main

    environment: production  # requires manual approval if configured

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
        run: |
          # your deploy script here

Secrets: how to do it right

Never put credentials in the YAML. Use GitHub Secrets, available in Settings > Secrets and variables > Actions of the repository.

- name: Deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  run: ./scripts/deploy.sh

For environments (production, staging), use Environment Secrets. They allow configuring different secrets per environment and adding required reviewers before deployment.

Matrix builds: testing across multiple versions

For libraries or APIs supporting multiple Node versions, matrix builds run CI in parallel across all combinations:

jobs:
  ci:
    strategy:
      matrix:
        node-version: [18, 20, 22]

    runs-on: ubuntu-latest
    name: Test on Node ${{ matrix.node-version }}

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

Artifacts: preserving outputs between jobs

If you want to use the build output in the deploy job, or preserve coverage reports, use artifacts:

- name: Upload coverage report
  uses: actions/upload-artifact@v4
  with:
    name: coverage-report
    path: coverage/

- name: Upload build
  uses: actions/upload-artifact@v4
  with:
    name: dist
    path: dist/
    retention-days: 7

The complete pipeline

name: CI/CD

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

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test --coverage
      - run: pnpm build

      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  deploy:
    needs: ci
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production

    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/

      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./scripts/deploy.sh

88 lines. Complete pipeline, secure, with cache, CI/CD separation, and conditional deploy. That's all most projects need.