intermediateDevOps

CI/CD Pipelines with GitHub Actions

Set up continuous integration and deployment pipelines from scratch. Automate testing, building, and deploying your applications.

50 min read8 sections
1

CI/CD: What It Is and Why Your Team Needs It Yesterday

Before CI/CD, deploying code looked like this: someone copies files to a server on Friday evening, prays nothing breaks over the weekend, and spends Monday morning fixing the things that broke. If this sounds familiar, you're not alone — a lot of teams still operate this way.

CI/CD replaces manual, error-prone deployments with automated pipelines that build, test, and deploy your code every time you push a change.

Continuous Integration (CI) means every developer merges their code into the shared branch frequently — ideally multiple times per day. Each merge triggers an automated process that builds the code and runs tests. If anything fails, the team knows within minutes.

Continuous Delivery (CD) extends this: code that passes CI is automatically packaged and ready to deploy. Actual deployment to production may still require someone clicking a button.

Continuous Deployment goes further — every change that passes the pipeline goes straight to production. No human approval. This requires excellent test coverage and monitoring, but companies like Netflix deploy thousands of times per day this way.

The payoff is real. The DORA (DevOps Research and Assessment) metrics consistently show that high-performing teams deploy 208 times more frequently than low performers, with 106 times faster lead time and 7 times lower change failure rate. CI/CD is the infrastructure that makes those numbers possible.

2

GitHub Actions: How It Works

GitHub Actions is CI/CD built directly into GitHub — no separate service to manage. It's free for public repos and includes 2,000 minutes/month for private repos on the free tier.

The mental model:

  • Workflow — A YAML file in .github/workflows/ that defines your automation
  • Trigger (event) — What kicks off the workflow: a push, a pull request, a schedule, or a manual button
  • Job — A collection of steps that run on a single virtual machine (runner)
  • Step — An individual command (npm test) or a reusable action (actions/checkout@v4)
  • Runner — The virtual machine that executes your job. GitHub provides Ubuntu, Windows, and macOS runners.

The YAML syntax takes some getting used to, but once you understand the hierarchy — workflow contains jobs, jobs contain steps — it clicks quickly. And the GitHub Marketplace has thousands of pre-built actions for common tasks, so you rarely have to write everything from scratch.

3

Your First Working CI Pipeline

Create a file at .github/workflows/ci.yml:

name: CI

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

jobs:
  build-and-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: Lint
        run: npm run lint

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

Push this file and GitHub immediately starts running it. Every future push to main and every PR targeting main will trigger this workflow. If any step fails, the workflow fails and your PR shows a red X.

A few notes on what's happening: npm ci is used instead of npm install because it's faster and reproducible (it uses the exact versions from package-lock.json). The cache: 'npm' option on setup-node caches the npm cache directory between runs so you're not downloading packages from scratch every time.

4

Managing Secrets and Environment Variables

Your pipeline will need API keys, database URLs, and deployment tokens. Never put these in your workflow file — they'd be visible to anyone who can see your repository.

Adding secrets: Go to your repository Settings, then Secrets and Variables, then Actions. Click "New repository secret." Secrets are encrypted at rest and masked in logs.

Using them in workflows:

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

      - name: Deploy to production
        run: ./deploy.sh
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Important security details:

  • Secrets from forks are not available in PR workflows. This prevents malicious PRs from external contributors from stealing your credentials.
  • Secrets are automatically redacted from workflow logs. If your secret is abc123 and your script prints it, the log shows *** instead.
  • For different environments (staging vs production), use GitHub's Environments feature. You can require manual approvals before production deployments.

One gotcha: if your secret contains special characters, you may need to quote it in shell commands. And never echo secrets for debugging — even masked, it's a bad habit to develop.

5

Matrix Builds: Testing Across Multiple Configurations

You want to verify your code works on Node 18, 20, and 22. And on both Ubuntu and Windows. Instead of writing separate jobs, use a matrix strategy:

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

    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 (2 operating systems x 3 Node versions). All six run simultaneously, so the total time is roughly the time of the slowest single job — not six times longer.

You can also structure independent jobs to run in parallel and then gate later jobs on their success:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build

Lint and test run simultaneously. Build only starts if both pass. This parallelism can cut your pipeline time in half.

6

Deploying to Production

Here's a real deployment workflow I've used for Next.js apps on Vercel (adapt for your platform):

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
      - run: npm run build

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

    steps:
      - uses: actions/checkout@v4
      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'

The environment: production setting enables environment protection rules — you can require manual approval before deploy runs, add wait timers, or restrict which branches can deploy.

Other deployment targets have their own actions:

  • AWS (S3, ECS, Lambda): aws-actions/configure-aws-credentials + AWS CLI
  • Docker Hub: docker/login-action + docker/build-push-action
  • Firebase: FirebaseExtended/action-hosting-deploy
  • Any server with SSH access: appleboy/ssh-action
7

Caching, Speed, and Keeping Pipelines Fast

A pipeline that takes 20 minutes to run is a pipeline people avoid triggering. Speed matters.

Caching dependencies is the biggest win. The setup-node action handles this with a single line (cache: 'npm'). For custom caches:

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

Cancel redundant runs. If you push three commits in quick succession, you don't need three pipeline runs. Cancel the older ones:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Skip unnecessary runs with path filters:

on:
  push:
    paths:
      - 'src/**'
      - 'package.json'
      - '.github/workflows/**'

This means documentation changes, README updates, or config file tweaks won't trigger a full CI run. Saves minutes and runner costs.

Other speed tips:

  • Run independent jobs in parallel (lint, test, type-check at the same time)
  • Use npm ci over npm install — it's 2–3x faster
  • Add timeout-minutes: 15 to jobs to prevent runaway processes from burning your minutes
8

Keeping Your Pipeline Healthy Long-Term

A CI/CD pipeline is infrastructure. Like all infrastructure, it degrades if you don't maintain it.

Reliability practices:

  • Pin action versions to specific tags: actions/checkout@v4, not @main. Unpinned actions can change under you and break your pipeline overnight.
  • Set up Slack or email notifications for failed workflows. A failed pipeline that nobody notices defeats the purpose.
  • Review third-party actions before using them. They run in your environment with access to your code and secrets.

Security practices:

  • Use the permissions key to limit what your workflow can access. Principle of least privilege applies to CI just like production systems.
  • Rotate secrets regularly — especially deployment tokens
  • Use OpenID Connect (OIDC) for cloud provider authentication instead of static credentials. It's more secure and doesn't require secret rotation.

Maintenance practices:

  • Delete unused workflow files. Orphaned workflows are confusing.
  • Monitor pipeline run times over time. If your 5-minute pipeline gradually becomes 15 minutes, investigate — it usually means dependencies grew or caching stopped working.
  • Use reusable workflows (workflow_call) to share common steps across multiple pipelines. DRY applies to CI/CD, too.

A well-maintained pipeline is one of the best investments your team can make. It catches bugs before users see them, enforces code quality standards automatically, and gives everyone confidence that merging to main means shipping to production safely. That confidence compounds over time into a genuinely faster, more reliable engineering organisation.

Ready to Take the Next Step?

Our tutorials are just the beginning. Explore our expert-led courses and certifications for hands-on, career-ready training.