M
MeshWorld.
GitHub Actions CI/CD DevOps GitHub Automation Developer Tools How-To 7 min read

How to Set Up CI/CD with GitHub Actions (Complete Guide)

Vishnu
By Vishnu

:::note[TL;DR]

  • Workflows are YAML files in .github/workflows/ — triggered by push, PR, schedule, or manual dispatch
  • Use needs: to chain jobs so deploy only runs after tests pass
  • Gate production deploys with if: github.ref == 'refs/heads/main' — PRs only hit staging
  • Never hardcode secrets — store them in Settings → Secrets → Actions and reference as ${{ secrets.NAME }}
  • Caching with actions/setup-node@v4 + cache: 'npm' eliminates redundant installs on every run :::

Prerequisites

  • A GitHub repository (public or private)
  • Your project has a test script in package.json (or equivalent for your language)
  • Basic familiarity with YAML syntax

GitHub Actions is GitHub’s built-in CI/CD system. Every push, pull request, or schedule can trigger a workflow — run tests, build a Docker image, deploy to your server. It’s free for public repos and has a generous free tier for private ones.

This guide goes from zero to a working pipeline: tests on every PR, deploy to production on merge to main.

How GitHub Actions works

Workflows are YAML files in .github/workflows/. Each file defines:

  • Triggers (on) — what events run the workflow
  • Jobs — parallel groups of steps
  • Steps — individual commands or pre-built actions

GitHub spins up a fresh VM for each job, runs the steps, and reports pass/fail.

Step 1: Create 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'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run build
        run: npm run build

Push this to GitHub. The Actions tab will show the workflow running on every push and PR.

Step 2: Add environment variables

For environment-specific values that aren’t secret (like API base URLs):

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      NODE_ENV: test
      API_URL: https://api.staging.example.com
    steps:
      - ...

Step 3: Add secrets

For API keys, passwords, and tokens — never hardcode them. Add secrets in: Repository → Settings → Secrets and variables → Actions

Then reference them in the workflow:

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

:::warning Never hardcode secrets directly in workflow YAML — even in private repos. If the repo ever becomes public, or a contributor forks it, the secret is exposed. Secrets in commit history are permanent: rotating the credential doesn’t remove it from git history. Store all sensitive values in Settings → Secrets and variables → Actions. :::

The scenario: You’re setting up CI for a side project at 11pm before a demo. You paste the API key directly in the YAML, push, it works. Two days later you realize the key is visible in your public repo’s commit history. Now you’re rotating credentials at midnight. Use secrets. Always.

Step 4: Add a deployment job

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 test

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

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to server
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SERVER_HOST: ${{ secrets.SERVER_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > /tmp/key
          chmod 600 /tmp/key
          ssh -i /tmp/key -o StrictHostKeyChecking=no user@$SERVER_HOST \
            'cd /app && git pull && npm ci && pm2 restart app'

needs: test ensures deploy only runs after tests pass. if: github.ref == 'refs/heads/main' skips deploy on PRs.

:::tip Always use needs: test before any deploy job. Without it, a deploy can run in parallel with tests — meaning broken code ships before the test failure is even reported. The needs: key creates an explicit dependency and blocks the deploy until all listed jobs succeed. :::

Step 5: Add a staging deployment

Common pattern — deploy PRs to a staging environment, main to production:

  deploy-staging:
    needs: test
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to staging
        run: ./scripts/deploy-staging.sh
        env:
          STAGING_URL: ${{ secrets.STAGING_URL }}

  deploy-production:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: ./scripts/deploy-prod.sh
        env:
          PROD_URL: ${{ secrets.PROD_URL }}

Step 6: Cache dependencies

Without caching, npm install runs from scratch on every workflow. With caching, it reuses the node_modules cache when package-lock.json hasn’t changed:

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'           # handles caching automatically

For pnpm:

- uses: pnpm/action-setup@v4
  with:
    version: 10

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

Useful actions

ActionWhat it does
actions/checkout@v4Check out your repo
actions/setup-node@v4Set up Node.js
actions/setup-python@v5Set up Python
docker/build-push-action@v5Build and push Docker image
actions/upload-artifact@v4Save build output
actions/cache@v4Manual dependency caching
peter-evans/create-pull-request@v6Create a PR from a workflow

Common triggers

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 9 * * 1'   # every Monday at 9am UTC
  workflow_dispatch:        # manual trigger from UI
  release:
    types: [published]

Debugging a failing workflow

  • Click the failed job in the Actions tab — each step shows its output
  • Add ACTIONS_RUNNER_DEBUG: true to repo secrets to get verbose logs
  • Use tmate action for SSH access into a running job (for tricky issues)
  • Add echo statements to print variable values mid-run
- name: Debug
  run: |
    echo "Node version: $(node -v)"
    echo "Branch: $GITHUB_REF"
    echo "Event: $GITHUB_EVENT_NAME"

Related: GitHub Actions Secrets Best Practices


Summary

  • GitHub Actions workflows live in .github/workflows/ as YAML files triggered by push, PR, schedule, or manual dispatch
  • Use needs: to chain jobs so deploy only runs after tests pass
  • Use if: github.ref == 'refs/heads/main' to gate production deploys to the main branch only
  • Never hardcode secrets — use GitHub’s encrypted secrets and reference them as ${{ secrets.NAME }}
  • Caching dependencies (actions/setup-node@v4 with cache: 'npm') can cut install time significantly

Frequently Asked Questions

How do I run a workflow only on certain file changes?

Use paths in the trigger:

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

The workflow only runs when those paths have changes. Useful for monorepos.

Why is my workflow triggered but the job is skipped?

Check your if: conditions. A common gotcha: if: github.ref == 'refs/heads/main' won’t match on a pull_request event because the ref is the PR branch. Use github.base_ref == 'main' for PRs or check github.event_name.

How do I share data between jobs?

Use actions/upload-artifact to save files from one job and actions/download-artifact to retrieve them in another. For simple values, use job outputs via echo "value=x" >> $GITHUB_OUTPUT and reference with needs.job_name.outputs.value.