Developer Tools

GitHub Actions CI/CD: Build, Test, and Deploy Workflows from Scratch

Learn how to set up GitHub Actions for continuous integration and deployment. Covers triggers, jobs, matrices, secrets, caching, Docker, and real-world workflow examples.

10 min read

GitHub code collaboration on screen

GitHub Actions turns every push, pull request, and merge into an automated pipeline — running tests, building artifacts, scanning for vulnerabilities, and deploying to production. It's the CI/CD platform that lives right inside your repository, with zero infrastructure to manage.

How GitHub Actions works

Every workflow is a YAML file in .github/workflows/. When a trigger event fires, GitHub spins up a fresh virtual machine, runs your jobs, and reports the results — all within seconds.

Push to main
  ↓
Workflow triggered
  ↓
Job: Build & Test (ubuntu-latest)
  ├── Step 1: Checkout code
  ├── Step 2: Install Node.js
  ├── Step 3: npm ci
  ├── Step 4: npm test
  └── Step 5: npm run build
  ↓
✅ All checks passed

Your first workflow

Create .github/workflows/ci.yml:

name: CI

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

jobs:
  build:
    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
      - run: npm run build

That's it. Every push to main and every PR now runs your test suite automatically.

Triggers: when workflows run

Code events

on:
  push:
    branches: [main, develop]
    paths: ['src/**', 'package.json']  # Only run when these change
  pull_request:
    types: [opened, synchronize]

Scheduled (cron)

on:
  schedule:
    - cron: '0 6 * * 1'  # Every Monday at 6:00 UTC

Manual dispatch

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        type: choice
        options: [staging, production]

This adds a "Run workflow" button in the GitHub UI where you can select parameters.

Reusable workflows

on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: '20'

Call it from another workflow with uses: ./.github/workflows/reusable.yml.

Jobs and steps

Parallel jobs

Jobs run in parallel by default:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  build:
    runs-on: ubuntu-latest
    needs: [lint, test]  # Waits for both to pass
    steps:
      - uses: actions/checkout@v4
      - run: npm run build

Matrix strategy — test across versions

Run the same job across multiple configurations:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, windows-latest]
      fail-fast: false
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This creates 6 parallel jobs (3 versions × 2 operating systems).

Secrets and environment variables

Using secrets

Store sensitive values in Settings → Secrets and variables → Actions:

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

Secrets are masked in logs and never exposed to forks.

Environment variables

# Workflow-level
env:
  NODE_ENV: production

jobs:
  build:
    # Job-level
    env:
      CI: true
    steps:
      - name: Build
        # Step-level
        env:
          VITE_API_URL: https://api.example.com
        run: npm run build

Caching for faster builds

Without caching, every run downloads all dependencies from scratch. Caching cuts build times by 50-80%:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm  # Built-in npm caching

# Or manual caching for other tools
- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

Artifacts: sharing data between jobs

Upload build outputs from one job and download in another:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
      - run: ./deploy.sh

Real-world workflow examples

Docker build and push

name: Docker

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

permissions:
  packages: write

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Deploy to Cloudflare Pages

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci && npm run build
      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          command: pages deploy dist --project-name=my-site

Release with changelog

name: Release

on:
  push:
    tags: ['v*.*.*']

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Generate changelog
        run: |
          git log $(git describe --tags --abbrev=0 HEAD^)..HEAD \
            --pretty=format:"- %s" > CHANGELOG.md
      - uses: softprops/action-gh-release@v2
        with:
          body_path: CHANGELOG.md
          generate_release_notes: true

Conditional execution

Control when steps and jobs run:

steps:
  - name: Deploy to production
    if: github.ref == 'refs/heads/main'
    run: ./deploy-prod.sh

  - name: Deploy to staging
    if: github.event_name == 'pull_request'
    run: ./deploy-staging.sh

  - name: Notify on failure
    if: failure()
    run: curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d '{"text":"Build failed!"}'

Security best practices

1. Pin action versions to SHA

# ❌ Mutable tag — could be compromised
- uses: actions/checkout@v4

# ✅ Pinned to exact commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

2. Use least-privilege permissions

permissions:
  contents: read    # Only what's needed
  packages: write

3. Never echo secrets

# ❌ NEVER do this
- run: echo ${{ secrets.API_KEY }}

# ✅ Use environment variables
- env:
    API_KEY: ${{ secrets.API_KEY }}
  run: ./script-that-uses-api-key.sh

4. Limit permissions for PRs from forks

on:
  pull_request_target:  # Runs in context of base branch
    types: [opened, synchronize]

Debugging failed workflows

  1. Check the logs — Click any failed step to see full output
  2. Add debug logging — Set secret ACTIONS_STEP_DEBUG to true
  3. Use act locally — Run workflows on your machine with nektos/act
  4. SSH into runner — Use mxschmitt/action-tmate for interactive debugging

Build workflows visually

Don't want to write YAML by hand? Use our GitHub Actions Generator to build workflows visually with templates for Node.js, Python, Docker, deployments, and more — then download production-ready YAML.

Wrapping up

GitHub Actions gives you CI/CD that lives inside your repository:

  1. Start simple — One workflow file with build + test
  2. Add caching — Cut build times with actions/cache or built-in language caching
  3. Use matrix — Test across versions and operating systems
  4. Protect secrets — Never hardcode, always use encrypted secrets
  5. Automate everything — Releases, deployments, security scans, scheduled tasks

The best CI/CD pipeline is the one that runs on every commit without you thinking about it. Set it up once, and let GitHub handle the rest.

GitHub Actions CI/CD: Build, Test, and Deploy Workflows from Scratch — FreeTool24 | FreeTool24