GitHub Actions CI/CD Tutorial for Beginners

Manual deployments are a hidden tax on every development team — the "deploy to production" ritual that blocks everyone, takes 20 minutes, and occasionally breaks at 6pm on a Friday. GitHub Actions is the answer that's already in your repository, costs nothing for public projects, and takes an afternoon to set up properly. This tutorial walks you through the core concepts and builds a real, working CI/CD pipeline from scratch.

What Is CI/CD?

Continuous Integration (CI) means automatically running tests and checks every time code is pushed. The goal is to catch bugs and integration issues immediately, before they compound. If tests fail on a branch, the developer knows instantly — not three days later when someone else tries to merge.

Continuous Deployment (CD) means automatically deploying code to production (or staging) after CI passes. When a developer merges to the main branch, the new code is live within minutes — automatically, without a human touching a deploy button.

Together, they reduce deployment risk by making deployments small and frequent, and eliminate manual steps that are error-prone and time-consuming.

GitHub Actions Concepts

Before writing YAML, understand the vocabulary:

  • Workflow — A YAML file in .github/workflows/. One repository can have multiple workflows (e.g., one for CI on PRs, one for deployment on merge to main).
  • Trigger (on:) — What causes the workflow to run: a push, a pull request, a schedule, or a manual dispatch.
  • Job — A set of steps that run on a single virtual machine (runner). Jobs within a workflow can run in parallel (default) or in sequence (using needs:).
  • Step — A single command or action within a job. Steps run sequentially. If a step fails, the job fails by default.
  • Action — A reusable unit of logic, published to the GitHub Marketplace. actions/checkout@v4 is the most commonly used action — it clones your repository into the runner.
  • Runner — The virtual machine that executes the job. GitHub-hosted runners run Ubuntu, macOS, or Windows. ubuntu-latest is the default and cheapest.
  • Secrets — Encrypted variables stored in GitHub Settings → Secrets. Never hardcode credentials in workflow files.

Your First Workflow

Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main, develop]
  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 linter
        run: npm run lint

      - name: Run tests
        run: npm test

That's a complete, working CI workflow. Every push to main or develop, and every pull request targeting main, will install dependencies and run lint + tests. The result (pass or fail) is shown on the PR before merging.

Key detail: npm ci instead of npm install. ci installs exactly what's in package-lock.json, cleans node_modules first, and fails if the lockfile is out of sync. It is faster and more deterministic in CI than npm install.

Adding Environment Variables and Secrets

Tests often need a database connection or API key. Store secrets in GitHub Settings → Secrets and variables → Actions → New repository secret. Reference them in the workflow with ${{ secrets.SECRET_NAME }}:

      - name: Run tests
        run: npm test
        env:
          DB_HOST: localhost
          DB_NAME: myapp_test
          DB_USER: postgres
          DB_PASS: ${{ secrets.TEST_DB_PASS }}
          JWT_SECRET: ${{ secrets.JWT_SECRET }}

Secrets are masked in workflow logs — even if a step accidentally prints them, GitHub replaces the value with ***.

Setting Up a PostgreSQL Service Container

Integration tests that require a real database need a database to connect to. GitHub Actions supports service containers — Docker containers that run alongside the job:

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB:       myapp_test
          POSTGRES_USER:     postgres
          POSTGRES_PASSWORD: test_password
        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 db:migrate
        env:
          DATABASE_URL: postgresql://postgres:test_password@localhost:5432/myapp_test
      - run: npm test
        env:
          DATABASE_URL: postgresql://postgres:test_password@localhost:5432/myapp_test

A Complete CI/CD Pipeline with Deployment

Now add a second job that deploys to a VPS via SSH after the tests pass. This workflow runs CI on every push, and only deploys when merging to main:

name: CI / CD

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

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

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1
        with:
          host:        ${{ secrets.SSH_HOST }}
          username:    ${{ secrets.SSH_USER }}
          key:         ${{ secrets.SSH_PRIVATE_KEY }}
          port:        ${{ secrets.SSH_PORT }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm ci --production
            npm run build
            pm2 reload myapp

Required secrets for the deploy job:

  • SSH_HOST — Your server's IP address or hostname
  • SSH_USER — The SSH username (e.g., ubuntu or deploy)
  • SSH_PRIVATE_KEY — The full content of your ~/.ssh/id_ed25519 private key
  • SSH_PORT — SSH port (22 by default, or your custom port)

On the server, add the corresponding public key to ~/.ssh/authorized_keys for the deploy user.

Matrix Builds: Testing Multiple Versions

If your library or application needs to support multiple Node.js versions, a matrix build runs the tests in parallel across all of them:

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

This creates three parallel jobs — one per Node version — each reporting separately. The workflow fails if any matrix variant fails.

Manual Deployments with workflow_dispatch

Sometimes you want to trigger a deployment manually, with optional parameters:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

This adds a "Run workflow" button to the Actions tab in GitHub, with a dropdown for environment. Useful for deploying to production only when you're ready, not on every merge.

Caching for Faster Pipelines

The cache: 'npm' option in actions/setup-node automatically caches node_modules based on the lockfile. A cache hit saves 30–90 seconds per run. For Python, the equivalent is cache: 'pip'; for PHP Composer, use actions/cache manually:

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}
          restore-keys: composer-

      - name: Install Composer dependencies
        run: composer install --no-dev --optimize-autoloader

Where to Deploy

GitHub Actions handles the pipeline — but you still need a server to deploy to. Our recommended targets for the SSH deploy workflow above:

  • DigitalOcean Droplets — $200 free credit for new users, straightforward SSH setup, excellent docs. Most of the examples in this post target a DigitalOcean Ubuntu server.
  • Hostinger VPS — KVM-based VPS from $4.99/month, works identically with the SSH deploy workflow.
  • Cloudways — if you want managed infrastructure (Nginx pre-configured, SSL automated) and prefer to deploy via git push rather than raw SSH commands.

Pricing

GitHub Actions is free for public repositories with unlimited minutes. For private repositories, the free tier includes 2,000 minutes per month on standard Ubuntu runners. An average workflow run takes 2–5 minutes, so 2,000 minutes covers roughly 400–1,000 workflow runs per month — enough for most small teams. Beyond the free tier, minutes are billed at $0.008/minute for Ubuntu.

Frequently Asked Questions

What is the difference between CI and CD?

CI (Continuous Integration) is about automatically testing code on every push — running linters, unit tests, and integration tests to catch bugs early. CD (Continuous Delivery or Continuous Deployment) is about automatically deploying tested code to an environment. Continuous Delivery means deploying to staging automatically, with a manual approval step before production. Continuous Deployment means deploying to production automatically after all tests pass, with no manual step. Most teams start with CI only, then add CD once they trust their test suite.

Can I run GitHub Actions on my own server?

Yes — self-hosted runners let you run workflow jobs on a machine you control. This is useful for accessing resources inside a private network (databases that aren't publicly accessible), using specific hardware, or avoiding billing. To set one up, go to Settings → Actions → Runners → New self-hosted runner and follow the installation script. Self-hosted runners can run any OS and are free to use with GitHub Actions (you pay for the machine, not the compute minutes).

How do I pass output from one step to the next?

Use the GITHUB_OUTPUT environment file. In the first step, write to it: echo "version=1.2.3" >> "$GITHUB_OUTPUT". In a later step, reference it with ${{ steps.step-id.outputs.version }}. To pass values between jobs (not just steps), write to GITHUB_OUTPUT and declare it as a job output, then reference it with ${{ needs.job-id.outputs.version }} in a dependent job.

What is the difference between `on: push` and `on: pull_request`?

on: push triggers when someone pushes commits directly to a branch. on: pull_request triggers when a pull request is opened, synchronised (new commits pushed to the PR branch), or reopened. A common pattern is to use on: pull_request for CI checks (so they appear on the PR before merging) and on: push with a branch filter (branches: [main]) for deployment (so it runs after merging). Using only on: push for CI means checks run after the commit — too late to block a merge.

My workflow runs on every push but I only want to deploy on merge to main. How?

Add a condition to the deploy job: if: github.ref == 'refs/heads/main' && github.event_name == 'push'. This ensures the deploy job only runs when the event is a push directly to main — not on PRs, not on pushes to other branches. Combined with needs: test, the deploy only runs when tests pass on a push to main — which is exactly what happens when a PR is merged.