πŸ“ Code πŸ”¨ Build πŸ§ͺ Test πŸš€ Deploy ⚑ GitHub Actions
DevOps

Setting Up CI/CD with GitHub Actions

Mayur Dabhi
Mayur Dabhi
March 19, 2026
22 min read

Someone merges a PR on Friday afternoon, nobody ran the tests, and the deploy script was run by hand from a laptop with a stale .env. By Monday the site is half broken and nobody's sure which commit did it. That's the problem CI/CD (Continuous Integration/Continuous Deployment) solves: every push gets built and tested the same way, and deploys happen from a known state instead of someone's machine.

If your code already lives on GitHub, GitHub Actions is the shortest path to that. This post walks through a first workflow, multi-job pipelines, secrets, matrix builds, caching, and a few complete examples for common stacks, plus the gotchas that tend to bite people the first time.

What's Covered
  • What CI and CD actually mean in practice
  • GitHub Actions core concepts: workflows, jobs, and steps
  • Creating your first workflow from scratch
  • Setting up automated testing pipelines
  • Building and deploying applications automatically
  • Working with secrets and environment variables
  • Advanced patterns: matrix builds, caching, and artifacts
  • Real-world workflow examples for different tech stacks

Understanding CI/CD

People use "CI/CD" as one word, but it's two separate habits. You can have the first without the second, and plenty of teams do.

Continuous Integration (CI)

Build and test every change when it's pushed. Broken code gets flagged on the PR, before it reaches main.

Continuous Deployment (CD)

Ship code that passed CI to staging or production without anyone running a script by hand. Releases get smaller and more frequent.

The CI/CD Pipeline Flow Continuous Integration πŸ‘¨β€πŸ’» Push Code πŸ”¨ Build πŸ§ͺ Test βœ… Code passes all tests β†’ Ready to deploy ❌ Tests fail β†’ Alert developer, block merge Continuous Deployment 🎭 Staging πŸš€ Production πŸ”„ Automatic deployment to servers πŸ“Š Monitoring & rollback ready πŸ‘₯ Users

CI checks the code on every push; CD takes what passed and ships it

Benefits of CI/CD

Benefit Description Impact
πŸ› Early Bug Detection Automated tests catch issues immediately after code changes Fewer regressions reach production
⚑ Faster Releases Automated deployment eliminates manual steps Deploying becomes routine, not an event
🀝 Better Collaboration Every PR is validated automatically before merge Reviewers focus on logic, not "does it build?"
πŸ“ Consistent Process Same build/test/deploy process every time Far fewer "works on my machine" arguments
↩️ Easy Rollbacks Versioned deployments with quick rollback capability Minimal downtime on issues

Introduction to GitHub Actions

The main reason to pick Actions over Jenkins or CircleCI is that there's nothing extra to set up. You drop a YAML file into the repo, push, and GitHub runs it. Check results show up right on the PR, secrets live in the repo settings, and there's no separate service to log into.

Why GitHub Actions?
  • Free for public repos: unlimited minutes on standard GitHub-hosted runners
  • 2,000 minutes/month free for private repos on the free plan (macOS and Windows minutes count at a higher rate)
  • Built into GitHub: checks appear on PRs, and you can trigger on issues, releases, tags and more
  • Marketplace: thousands of ready-made actions, though quality varies a lot
  • Matrix builds: test several OS/version combinations in parallel

Core Concepts

Four terms cover almost everything: a workflow is the YAML file, an event triggers it, it contains one or more jobs, and each job is a list of steps.

GitHub Actions Structure πŸ“‹ Workflow (YAML file) .github/workflows/ci.yml ⚑ Trigger Event push, pull_request πŸ–₯️ Job: test runs-on: ubuntu-latest Step 1: Checkout code Step 2: Setup Node.js Step 3: Run tests πŸš€ Job: deploy needs: test Step 1: Build app Step 2: Deploy βœ… Complete!

A workflow contains jobs, jobs contain steps, and steps run commands or actions

Creating Your First Workflow

Start small: one workflow that installs dependencies, runs tests and runs the linter on every push. You can bolt on builds and deploys once this one is green.

Step 1: Create the Workflow Directory

GitHub only picks up workflow files from .github/workflows/ at the repo root. Put them anywhere else and they're silently ignored.

your-project/
.github/
workflows/
ci.yml
deploy.yml
src/
package.json

Step 2: Write Your First Workflow

Create a file at .github/workflows/ci.yml with the following content:

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

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

jobs:
  test:
    # The type of runner the job will run on
    runs-on: ubuntu-latest

    steps:
      # Check out the repository code
      - name: Checkout code
        uses: actions/checkout@v4

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

      # Install dependencies
      - name: Install dependencies
        run: npm ci

      # Run tests
      - name: Run tests
        run: npm test

      # Run linting
      - name: Run linter
        run: npm run lint
Understanding the Workflow
  • name: the label shown in the Actions tab
  • on: events that trigger the workflow (push, PR, schedule, etc.)
  • jobs: one or more jobs, which run in parallel unless you chain them with needs
  • runs-on: the runner type (ubuntu, windows, macos)
  • steps: commands or actions, run in order
  • uses: pulls in a pre-built action
  • run: runs a shell command

One thing that trips people up: npm ci fails if package.json and package-lock.json are out of sync ("npm ci can only install packages when your package.json and package-lock.json are in sync"). That's on purpose. Commit the lock file, and run npm install locally after editing dependencies.

Multi-Job Workflows

Once you add a build and a deploy, it makes sense to split them into separate jobs. Each job gets a fresh runner, so anything one job produces (like a dist/ folder) has to be passed to the next as an artifact.

.github/workflows/full-pipeline.yml
name: Full CI/CD Pipeline

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

jobs:
  # Job 1: Run tests
  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

  # Job 2: Build the application
  build:
    runs-on: ubuntu-latest
    needs: test  # Waits for test job to complete
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      
      # Upload build artifacts
      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

  # Job 3: Deploy to production
  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'  # Only deploy on main
    steps:
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/

      - name: Deploy to server
        run: |
          echo "Deploying to production..."
          # Add your deployment commands here
Job Dependencies Flow πŸ§ͺ test needs: test πŸ”¨ build needs: build πŸš€ deploy if: github.ref == 'main'

Jobs run sequentially when they depend on each other using the needs keyword

Working with Secrets

API keys, SSH keys and passwords don't belong in the YAML. Store them as encrypted secrets in the repo settings and GitHub injects them when the job runs.

1

Add Secrets in Repository Settings

Go to Settings β†’ Secrets and variables β†’ Actions β†’ New repository secret

2

Use Secrets in Workflows

Access secrets using the ${{ secrets.SECRET_NAME }} syntax

Using secrets in deployment
name: Deploy with Secrets

on:
  push:
    branches: [ main ]

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

      - name: Deploy to server
        env:
          SERVER_HOST: ${{ secrets.SERVER_HOST }}
          SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          echo "$SSH_KEY" > key.pem
          chmod 600 key.pem
          scp -i key.pem -r ./dist/* user@$SERVER_HOST:/var/www/app/
          rm key.pem

      - name: Notify deployment
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        run: |
          curl -X POST -H 'Content-type: application/json' \
            --data '{"text":"Deployment successful! πŸš€"}' \
            $SLACK_WEBHOOK
Security Best Practices
  • Never echo or log secret values
  • Use environment-specific secrets for staging vs production
  • Rotate secrets regularly and after team member changes
  • Limit secret access to necessary workflows only
  • Use GITHUB_TOKEN for GitHub API operations (auto-provided)
  • Secrets aren't passed to workflows triggered by pull_request from forks, so a deploy step that works on your own branches will see empty values on outside contributors' PRs

Matrix Builds

If you publish a library or a CLI, your users won't all be on your Node version or your OS. A matrix runs the same job once per combination of values, in parallel, so you find the Windows path bug before someone files an issue about it.

Matrix build example
name: Matrix CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [18, 20, 22]
        exclude:
          # Skip Node 18 on Windows to save time
          - os: windows-latest
            node-version: 18
      fail-fast: false  # Don't cancel other jobs if one fails

    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      
      - run: npm ci
      - run: npm test

      - name: Report results
        run: |
          echo "βœ… Tests passed on ${{ matrix.os }} with Node ${{ matrix.node-version }}"

This configuration creates 8 parallel jobs (3 OS Γ— 3 Node versions, minus 1 excluded combination), all running at the same time. Keep an eye on the bill for private repos, though: macOS and Windows minutes cost more than Linux ones.

Matrix Build Visualization Node 18 Node 20 Node 22 🐧 Ubuntu βœ“ Running βœ“ Running βœ“ Running πŸͺŸ Windows ⊘ Excluded βœ“ Running βœ“ Running 🍎 macOS βœ“ Running βœ“ Running βœ“ Running 8 parallel jobs

Matrix builds test all combinations in parallel, catching platform-specific bugs early

Caching for Faster Builds

Without a cache, every run downloads your whole dependency tree again. Caching keeps the package cache between runs, keyed on the lock file, so it's only rebuilt when dependencies actually change.

Caching dependencies
name: CI with Caching

on: [push, pull_request]

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

      # Method 1: Built-in cache in setup-node
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'  # Caches ~/.npm, not node_modules

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

      - run: npm ci
      - run: npm run build
      - run: npm test

Cache Performance Impact

How much you save depends on the project, but the pattern is usually the same:

  • Node.js projects: the install step gets noticeably shorter once the npm/yarn cache is warm
  • Docker builds: layer caching helps most when the Dockerfile copies the lock file and installs before copying source
  • Compiled languages: build caches pay off most on large codebases

Use one of the two methods above, not both. They cache the same directory, so doing both just adds an extra restore and save step.

Real-World Workflow Examples

Three complete workflows I'd use as starting points: a Next.js app deployed to Vercel, a Node API tested against a real Postgres container, and a Docker image pushed to Docker Hub.

React/Next.js Deployment

name: Deploy Next.js to Vercel

on:
  push:
    branches: [ main ]

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

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci
      - run: npm run lint
      - run: npm run test
      - run: npm run build

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID }}
          vercel-project-id: ${{ secrets.PROJECT_ID }}
          vercel-args: '--prod'

Node.js API with PostgreSQL

name: API CI/CD

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci
      - run: npm run migrate
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
      - run: npm test
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test

Docker Build & Push

name: Docker Build & Push

on:
  push:
    branches: [ main ]
    tags: [ 'v*' ]

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: user/app:latest,user/app:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Best Practices & Tips

Security Best Practices

  • Pin action versions: use actions/checkout@v4.1.1 instead of @v4 or @main. For third-party actions, pinning to a full commit SHA is safer still, since tags can be moved
  • Audit third-party actions: read the source before you hand an action your secrets
  • Use GITHUB_TOKEN: prefer the built-in token over personal access tokens
  • Limit token permissions: set a permissions: block with only what the workflow needs. If a step fails with "Resource not accessible by integration", it's usually missing a permission here
  • Environment protection rules: require approvals for production deployments

Performance Optimization

  • Cache what's expensive to rebuild: dependencies, build outputs, and Docker layers
  • Run jobs in parallel: jobs without needs already run at the same time
  • Trim the matrix: exclude combinations nobody actually uses
  • Fail fast: fail-fast: true (the default) cancels the rest of the matrix on the first failure
  • Choose the right runner: ubuntu-latest usually starts fastest and costs the least

Debugging Workflows

  • Enable debug logging: set the ACTIONS_RUNNER_DEBUG and ACTIONS_STEP_DEBUG secrets (or variables) to true, or tick "Enable debug logging" when re-running a job
  • Use step outputs: pass values between steps with echo "value=$VALUE" >> $GITHUB_OUTPUT
  • SSH debugging: mxschmitt/action-tmate opens an interactive session on the runner
  • Check run artifacts: upload logs and test reports as artifacts so you can inspect them later
  • Use act locally: nektos/act runs workflows on your machine, though it doesn't match GitHub's runners exactly

When I'd use GitHub Actions (and when I wouldn't)

If the code is on GitHub and the pipeline is "install, test, build, deploy", I'd reach for Actions first. There's no server to maintain, the YAML is versioned with the code, and PR checks just work. For most web apps and libraries that's all you need.

It's a weaker fit in a few cases:

Where it gets awkward
  • Your code isn't on GitHub. GitLab CI or your existing tool will be less friction.
  • Long or heavy builds on private repos. Minutes add up fast, especially on macOS. Self-hosted runners help, but then you're maintaining machines again.
  • Deploys that need access to a private network. GitHub-hosted runners sit on the public internet, so you'll need a self-hosted runner, a VPN step, or a pull-based deploy.
  • Very complex pipelines. Once you have dozens of interdependent jobs and reusable workflows calling each other, YAML gets hard to reason about, and a dedicated tool may be easier to maintain.

Before trusting a pipeline with production, check that branch protection actually requires the CI check to pass, that the production environment requires approval, and that you've tested a rollback at least once. A green pipeline that nobody's forced to wait for doesn't protect anything.

CI/CD GitHub Actions Automation DevOps Workflows
Mayur Dabhi

Mayur Dabhi

Full-stack developer passionate about clean code, automation, and building great developer experiences. I write about web development, DevOps, and software engineering best practices.