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 Debug Node.js Memory Leaks (Step-by-Step Guide)

Learn how to detect, diagnose, and fix Node.js memory leaks using heap snapshots, Chrome DevTools, and clinic.js — with real code examples.

Running Local LLMs With Ollama: Developer Setup Guide

Set up Ollama to run local LLMs on your machine. Covers installation, model selection, API usage, and integrating local models into your dev workflow.

Python Virtual Environments Explained: venv vs conda vs pyenv

A practical comparison of Python's venv, conda, and pyenv — when to use each, how to set them up, and which one fits your workflow.