← Back to Blog

GitHub Actions CI/CD: Complete Beginner to Advanced Guide

GitHub Actions is the most widely adopted CI/CD platform for open-source and private repositories. This guide covers everything from your first workflow to advanced patterns: matrix builds, reusable workflows, Docker-based CI, OIDC deployments, self-hosted runners, and debugging techniques.

What Are GitHub Actions?

GitHub Actions is a workflow automation platform built directly into GitHub. It runs arbitrary code in response to events that happen in your repository — a push, a pull request, a release tag, a schedule, or a manual trigger. Each workflow is defined as a YAML file under .github/workflows/ in your repository.

The key concepts are:

  • Workflow — a YAML file that defines one or more jobs, triggered by one or more events.
  • Event — the trigger: push, pull_request, schedule, workflow_dispatch, etc.
  • Job — a set of steps that runs on a single runner (VM). Jobs run in parallel by default.
  • Step — an individual task within a job: a shell command (run) or a pre-built action (uses).
  • Action — a reusable unit of automation, either from the GitHub Marketplace or defined inline. Examples: actions/checkout@v4, docker/build-push-action@v5.
  • Runner — the machine that executes the job. GitHub-hosted runners are Ubuntu, Windows, or macOS VMs. You can also bring your own self-hosted runners.

Your First Workflow: Hello World

Create a file at .github/workflows/ci.yml in your repository:

name: CI

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

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Check out 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 tests
        run: npm test

      - name: Build
        run: npm run build

When you push this file to your repository, GitHub detects it immediately. The next push to main or any pull request targeting main will trigger this workflow. The job runs on an Ubuntu VM, checks out your code, sets up Node.js 20, installs dependencies, runs tests, and builds.

Validate Your Workflow YAML

GitHub Actions workflows are YAML files. Catch indentation errors and syntax issues before you push with our free YAML Validator — client-side, instant feedback.

Open YAML Validator

Workflow Triggers (on:)

The on: key controls when your workflow runs. Here are the most useful triggers:

Push and Pull Request

on:
  push:
    branches: [main, 'release/**']
    paths:
      - 'src/**'         # only trigger if files in src/ changed
      - '!src/**/*.md'   # but ignore .md files in src/
    tags:
      - 'v*'             # trigger on version tags like v1.0.0

  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]  # default behavior

Schedule (Cron)

on:
  schedule:
    - cron: '0 2 * * *'   # daily at 02:00 UTC
    - cron: '0 9 * * 1'   # every Monday at 09:00 UTC

Scheduled workflows run against the default branch. They only run if the workflow file exists on that branch. Use our Cron Parser to verify your cron expressions.

Manual Trigger (workflow_dispatch)

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options: [staging, production]
      dry_run:
        description: 'Dry run (no actual deploy)'
        type: boolean
        default: false

This adds a "Run workflow" button in the GitHub UI under the Actions tab. You can pass inputs that are accessible via ${{ inputs.environment }} in the workflow. This is ideal for deployment workflows you want to trigger on demand.

Other Useful Triggers

on:
  release:
    types: [published]    # runs when you publish a GitHub Release

  workflow_run:
    workflows: ["CI"]
    types: [completed]    # runs after another workflow completes

  repository_dispatch:    # triggered via GitHub API from external systems
    types: [deploy]

Jobs: Parallelism and Dependencies

By default, all jobs in a workflow run in parallel. Use needs: to create dependencies between jobs.

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

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

  build:
    runs-on: ubuntu-latest
    needs: [test, lint]   # only runs if both test and lint succeed
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build

  deploy:
    runs-on: ubuntu-latest
    needs: build          # only runs if build succeeds
    if: github.ref == 'refs/heads/main'  # only deploy from main
    steps:
      - run: echo "Deploying..."

Here test and lint run in parallel. build waits for both. deploy only runs if build passes AND we are on the main branch.

Matrix Builds

Matrix builds let you run the same job across multiple combinations of values — different Node.js versions, operating systems, or any other dimension — without duplicating code.

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: [18, 20, 22]
        exclude:
          - os: windows-latest
            node: 18   # skip this specific combination
      fail-fast: false  # don't cancel other matrix jobs on first failure

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test

This generates 8 parallel jobs (3 OS x 3 Node versions, minus the excluded combination). The matrix values are accessible as ${{ matrix.os }} and ${{ matrix.node }} throughout the job.

Secrets and Environment Variables

Never hardcode credentials in workflow files. GitHub provides encrypted secrets storage at the repository, environment, and organization level.

# Add a secret in GitHub: Settings > Secrets and variables > Actions > New repository secret
# Then reference it in your workflow:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production    # uses environment-level secrets
    steps:
      - name: Deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: |
          aws s3 sync ./dist s3://my-bucket/

GitHub automatically redacts secret values if they appear in log output, replacing them with ***. Secrets are only exposed to workflow runs triggered from the repository itself — forks do not get access to parent repository secrets.

Environment secrets are scoped to a named deployment environment (like production) and can require manual approval before a job accessing them starts — a critical guard for production deployments.

Caching Dependencies

GitHub Actions runners start fresh for every job. Without caching, every run re-downloads packages. For a large Node project, that is 60+ seconds per run. The actions/cache action solves this.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'   # built-in caching shortcut for npm/yarn/pnpm

      - run: npm ci
      - run: npm test

The cache: 'npm' shortcut in setup-node automatically caches ~/.npm using package-lock.json as the cache key. For more control:

      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

The cache key includes the hash of package-lock.json. When the lockfile changes, a new cache is created. restore-keys provides fallback prefixes so a partial cache hit is used when an exact match is not found.

Artifacts: Sharing Files Between Jobs

Artifacts persist files from one job so other jobs (or humans) can download them. This is how you pass a compiled build from a build job to a deploy job.

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

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 7

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

      - name: Deploy to S3
        run: aws s3 sync dist/ s3://my-bucket/

Docker Containers in CI

GitHub Actions integrates deeply with Docker — you can build images, push to registries, and even run your CI steps inside a custom container.

Build and Push to Docker Hub or ECR

name: Build and Push Docker Image

on:
  push:
    branches: [main]

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            myorg/myapp:latest
            myorg/myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The cache-from: type=gha / cache-to: type=gha lines enable BuildKit layer caching via GitHub Actions cache, dramatically speeding up subsequent builds when only a few layers change.

Run Steps Inside a Container

jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: node:20-alpine
      env:
        NODE_ENV: test
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
        env:
          DATABASE_URL: postgres://postgres:testpass@postgres:5432/testdb

The services: block spins up sidecar containers (a PostgreSQL database here) that your test steps can connect to by hostname (postgres in this case). This is the cleanest way to run integration tests against a real database in CI.

Reusable Workflows

Reusable workflows let you extract a workflow into its own file and call it from other workflows — eliminating duplication across repositories or multiple workflow files.

# .github/workflows/deploy-reusable.yml
name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image_tag:
        required: true
        type: string
    secrets:
      AWS_ROLE_ARN:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1
      - name: Deploy
        run: |
          echo "Deploying ${{ inputs.image_tag }} to ${{ inputs.environment }}"
# .github/workflows/ci.yml — calling the reusable workflow
jobs:
  build:
    # ... build job ...

  deploy-staging:
    needs: build
    uses: ./.github/workflows/deploy-reusable.yml
    with:
      environment: staging
      image_tag: ${{ github.sha }}
    secrets:
      AWS_ROLE_ARN: ${{ secrets.STAGING_AWS_ROLE_ARN }}

  deploy-production:
    needs: deploy-staging
    uses: ./.github/workflows/deploy-reusable.yml
    with:
      environment: production
      image_tag: ${{ github.sha }}
    secrets:
      AWS_ROLE_ARN: ${{ secrets.PROD_AWS_ROLE_ARN }}

OIDC: Keyless AWS / GCP / Azure Auth

Instead of storing long-lived AWS access keys as secrets, GitHub Actions supports OpenID Connect (OIDC). Your workflow exchanges a short-lived GitHub token for temporary cloud credentials, with no static keys stored anywhere.

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # required for OIDC
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
          role-session-name: github-actions
          aws-region: us-east-1

      - name: Deploy
        run: aws s3 sync dist/ s3://my-bucket/

For this to work, you must set up an IAM OIDC identity provider in AWS pointing to token.actions.githubusercontent.com and configure the IAM role trust policy to allow only your specific repository (and optionally branch) to assume it.

Self-Hosted Runners

GitHub-hosted runners are great for most cases, but self-hosted runners give you control over the hardware, installed software, network access to private resources, and cost (no per-minute billing).

# Target a self-hosted runner by label
jobs:
  build:
    runs-on: [self-hosted, linux, x64, production]
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

To register a self-hosted runner: go to your repository's Settings > Actions > Runners > New self-hosted runner, and follow the instructions for your OS. The runner agent connects outbound to GitHub — no inbound firewall rules needed.

Security note: Never use self-hosted runners on public repositories. A malicious pull request could run arbitrary code on your runner machine.

Expressions, Contexts, and Conditionals

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Only run on main branch pushes, not PRs
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Only on tag
        if: startsWith(github.ref, 'refs/tags/v')
        run: echo "This is a release tag"

      - name: Print event info
        run: |
          echo "Event: ${{ github.event_name }}"
          echo "Ref: ${{ github.ref }}"
          echo "SHA: ${{ github.sha }}"
          echo "Actor: ${{ github.actor }}"
          echo "Run number: ${{ github.run_number }}"

Useful contexts: github (event metadata), env (environment variables), steps (outputs from previous steps), runner (runner info), secrets (secret values), inputs (workflow_dispatch inputs).

Passing Data Between Steps

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Generate version
        id: version
        run: echo "tag=v$(date +%Y%m%d)-${{ github.run_number }}" >> "$GITHUB_OUTPUT"

      - name: Use version in next step
        run: echo "Building ${{ steps.version.outputs.tag }}"

      - name: Set environment variable for all subsequent steps
        run: echo "BUILD_TAG=${{ steps.version.outputs.tag }}" >> "$GITHUB_ENV"

      - name: Available in all later steps
        run: echo "Tag is $BUILD_TAG"

Use $GITHUB_OUTPUT to pass values between steps within the same job. Use $GITHUB_ENV to set environment variables that persist for all subsequent steps. To pass data between jobs, use artifacts or job outputs:

jobs:
  setup:
    outputs:
      version: ${{ steps.get_version.outputs.version }}
    steps:
      - id: get_version
        run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"

  build:
    needs: setup
    steps:
      - run: echo "Version is ${{ needs.setup.outputs.version }}"

Debugging Failed Workflows

When a workflow fails, here are the tools available:

  • Step logs: Click on any step in the Actions UI to expand its log output. Look for the exact error message.
  • Enable debug logging: Add a secret named ACTIONS_STEP_DEBUG with value true to get verbose runner and action logs. Add ACTIONS_RUNNER_DEBUG for runner-level debug output.
  • Re-run with debug logging: In the Actions UI, click "Re-run jobs" and check "Enable debug logging" — no secret needed.
  • tmate SSH session: Use the mxschmitt/action-tmate action to pause a run and SSH into the runner for interactive debugging.
      - name: Debug: interactive SSH session
        uses: mxschmitt/action-tmate@v3
        if: ${{ failure() }}   # only open SSH session on failure

Production Best Practices

  • Pin action versions to a full SHA, not a floating tag like @v4: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683. This prevents supply chain attacks where an action tag is moved to malicious code.
  • Use OIDC for cloud auth — never store long-lived cloud credentials as repository secrets.
  • Set minimal permissions on GITHUB_TOKEN using the permissions: key at workflow or job level. Default to read-only and grant only what each job needs.
  • Use environment protection rules for production deployments — require a manual reviewer to approve before the job runs.
  • Cache aggressively — dependencies, Docker layers, and compiled output. This cuts average CI time by 50-70%.
  • Separate CI and CD — your CI workflow tests and builds; a separate CD workflow (or deployment job) deploys. Keep them distinct for clarity and access control.
  • Use concurrency groups to cancel in-progress runs when new ones are triggered:
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
  • Validate YAML syntax before pushing — a misconfigured workflow silently does nothing. Use our YAML Validator to catch errors locally.
  • Use timeout-minutes on jobs and steps to prevent runaway jobs from consuming all your Actions minutes.
  • Store reusable workflows in a dedicated .github repository in your organization, and reference them across all repos with uses: org/.github/.github/workflows/deploy.yml@main.

The Bottom Line

GitHub Actions rewards investment in workflow design. Start simple — a three-step CI workflow that checks out, installs, and tests. Then layer in caching for speed, matrix builds for compatibility, OIDC for secure deployments, and reusable workflows to eliminate duplication across your organization.

The most common mistakes are: committing secrets as plaintext, using floating action tags, skipping dependency caching, and mixing CI and CD concerns in one enormous workflow file. Avoid those and you will have a fast, secure, maintainable pipeline.

Related tools: YAML Validator (validate workflow files), Cron Parser (verify schedule triggers), ENV Validator (check env vars), and 70+ more free developer tools.