MervCodes

Tech Reviews From A Programmer

How to Set Up GitHub Actions for CI/CD (Beginner-Friendly Guide)

10 min read

If you're still SSHing into servers to deploy, or running tests manually before merging PRs, you're burning time you'll never get back. I put off setting up CI/CD for way too long on personal projects because it felt like "extra work" — turns out the 15 minutes of setup saves hours every week.

GitHub Actions lets you automate your entire build-test-deploy pipeline directly from your repo. No Jenkins server to babysit, no CircleCI account to manage, and it's free for public repos with a generous free tier for private ones.

Let me walk you through setting it up from scratch.

TL;DR

  • Workflow files live in .github/workflows/ — they're just YAML
  • Triggers: push, pull_request, schedules, or manual dispatch
  • Free tier: 2,000 minutes/month for private repos, unlimited for public
  • You can go from zero to working CI/CD in under 15 minutes
  • Caching and matrix strategies keep builds fast at scale

What Is GitHub Actions?

It's GitHub's built-in CI/CD platform. Your workflow definitions live alongside your code, version-controlled and reviewable in PRs. No separate infrastructure to maintain.

It's event-driven: push code, run tests. Open a PR, lint and preview. Merge to main, deploy to production. You define these automations in YAML, GitHub runs them on hosted runners (Ubuntu, macOS, or Windows).

Over 90% of the top 1,000 GitHub repos use it as of 2025. For good reason — it just works.

Pricing

Public repos: completely free, unlimited minutes. Private repos: 2,000 minutes/month on Linux. Most small-to-medium teams never hit the limit.

Step 1: Your First Workflow

Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  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"

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

Commit, push, done. Every push to main and every PR triggers this pipeline.

Breaking it down:

  • on — trigger events. Pushes and PRs to main.
  • jobs — each job gets a fresh VM.
  • runs-onubuntu-latest is cheapest and most common.
  • steps — pre-built actions (uses) or shell commands (run).

Step 2: Add Caching

Without caching, npm ci downloads everything on every run. That's 30-90 seconds wasted.

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 run lint
      - run: npm test

The cache: "npm" option caches your ~/.npm directory. Cuts install time by 50-70% on subsequent runs. One line, massive improvement.

Step 3: Matrix Strategy

Test across multiple Node versions:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"

      - run: npm ci
      - run: npm test

Three parallel jobs, one per version. Total pipeline time is roughly the same as a single run.

Step 4: Add Deployment

Deploy to production only on merge to main, only after tests pass:

name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    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 run lint
      - run: npm test
      - run: npm run build

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

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - run: npm ci
      - run: npm run build

      - name: Deploy to production
        run: |
          npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}

Key bits:

  • needs: test — deploy waits for tests to pass.
  • if condition — only deploys on pushes to main, not PR checks.
  • secrets.VERCEL_TOKEN — sensitive values go in GitHub Secrets.

If you're deploying to AWS EC2 instead, check out our guide on deploying Node.js apps to EC2 which covers SSH-based deployments.

Step 5: Secure Your Secrets

Never hardcode credentials. GitHub Secrets are encrypted, only exposed during workflow runs, and masked in logs.

  1. Repo → Settings → Secrets and variables → Actions
  2. New repository secret
  3. Add keys like VERCEL_TOKEN, AWS_ACCESS_KEY_ID
  4. Reference as ${{ secrets.YOUR_SECRET_NAME }}

Pitfalls I've Hit

Running out of minutes

If your pipeline runs on every push to every branch, minutes vanish fast. Limit triggers:

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

Slow pipelines

A 15+ minute pipeline kills momentum. Quick wins:

  • Cache deps — 30-90 seconds saved per run
  • npm ci over npm install — faster and deterministic
  • Parallel jobs — lint and test don't need to be sequential
  • Path filters — skip CI when only docs change:
on:
  push:
    branches: [main]
    paths-ignore:
      - "**.md"
      - "docs/**"

Unpinned action versions

Don't use @main or @latest:

# Good
- uses: actions/checkout@v4

# Better (pinned to SHA)
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

# Bad
- uses: actions/checkout@main

No environment protection

For production deploys, enable protection rules: manual approval, branch restrictions, wait timers. Settings → Environments.

Real-World: Docker + Postgres

name: CI/CD

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

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

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

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm test
        env:
          DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb

  build-and-push:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

The services block spins up a real Postgres alongside your tests — one of GitHub Actions' most underrated features.

Actions Worth Knowing

  • actions/checkout@v4 — every workflow needs this
  • actions/setup-node@v4 — Node.js with built-in caching
  • actions/cache@v4 — general-purpose caching
  • docker/build-push-action@v6 — Docker builds
  • github/codeql-action@v3 — security scanning
  • actions/upload-artifact@v4 — store build outputs

Debugging Failures

  1. Click the failed run in the Actions tab
  2. Expand the failed step for logs
  3. Set ACTIONS_RUNNER_DEBUG secret to true for verbose output
  4. Use act to test workflows locally

For teams setting up more complex pipelines — multi-environment deploys, custom runners, IaC integration — Adaptels offers DevOps consulting.

Wrapping Up

GitHub Actions removes the friction between writing code and shipping it. Start with a test workflow, add caching, layer on deployment. You don't need the perfect pipeline on day one — iterate like you would any other code.

The configs in this guide are production-ready starting points. Copy them, adjust the Node version and deploy step, and you'll have CI/CD running in minutes.

Sources

  1. GitHub Actions Documentation — Official docs covering workflows, syntax, and runner specs
  2. GitHub Actions Billing and Usage — Free tier limits and pricing details
  3. actions/setup-node — Official Node.js setup action with built-in caching
  4. Docker Build Push Action — Official Docker action for building and pushing images
  5. GitHub Actions Security Hardening — Best practices for securing your CI/CD pipelines

Related Articles

How to Set Up AI Code Review in GitHub Actions (2026 Guide)

Wire an AI code reviewer into GitHub Actions the right way — trigger on pull requests, post inline comments, keep secrets safe, and avoid the noisy-bot trap. Complete working workflow included.

AI Code Review vs Human Code Review: When to Use Each (2026)

AI code review and human review aren't competitors — they're a division of labour. What each is good at, where each fails, and how to combine them so you ship faster without lowering the bar.

How to Reduce Pull Request Review Time (Without Cutting Corners)

Slow PR reviews are usually a process problem, not a people problem. How to cut review turnaround — smaller PRs, clearer descriptions, an AI first pass, and review SLAs — without lowering your quality bar.