← Back to DevBytes

GitHub Actions: Complete Configuration Guide

What is GitHub Actions?

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform built directly into GitHub repositories. It allows developers to automate workflows in response to repository events—such as pushing code, opening pull requests, or creating issues. At its core, GitHub Actions executes custom scripts and commands inside disposable virtual machines called runners, triggered by events you define.

Think of it as a programmable automation engine that lives alongside your code. You define workflows as YAML files stored in the .github/workflows directory of your repository. Each workflow consists of one or more jobs, which are collections of steps that run sequentially. Steps can invoke shell commands, run scripts, or call reusable community-built actions from the GitHub Marketplace.

The key components of GitHub Actions include:

Why GitHub Actions Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

GitHub Actions transforms your repository from a static codebase into a living, self-testing, self-deploying system. Its significance extends beyond simple automation—it fundamentally changes how teams collaborate and ship software.

First, tight integration with GitHub means you can trigger workflows from nearly any repository event: commits, pull requests, issue comments, releases, and even webhook events from external services. This eliminates the need for external CI services and their associated configuration overhead.

Second, the community-powered Marketplace contains thousands of pre-built actions maintained by developers worldwide. Need to deploy to AWS, run linters, send Slack notifications, or scan for vulnerabilities? There is likely an action ready to use, saving hours of scripting.

Third, cost efficiency is compelling. Public repositories receive unlimited free minutes on GitHub-hosted runners. Even private repositories get a generous monthly allowance before billing kicks in. For larger organizations, self-hosted runners provide complete control over infrastructure costs and security.

Key scenarios where GitHub Actions excels:

Core Concepts and Terminology

Workflows

A workflow is a single YAML file in .github/workflows/ that defines a complete automation routine. A repository can have multiple workflows, each handling different events or tasks. For example, you might have one workflow for CI testing and another for deployment.

Events

Events are the triggers that start a workflow. Common events include:

Jobs

A job is a set of steps that execute on the same runner. By default, jobs run in parallel, but you can define dependencies to create sequential execution chains using the needs keyword.

Steps

Steps are the individual actions or shell commands that comprise a job. Steps run sequentially within a job. A step can either use a pre-built action with uses or execute a shell command with run.

Runners

A runner is the virtual machine that executes your workflow. GitHub provides hosted runners with Ubuntu, Windows, and macOS environments, or you can register self-hosted runners on your own infrastructure.

Actions

Actions are reusable units of code that can be referenced by any workflow. They are defined in public repositories or in your own repository. Actions accept inputs, produce outputs, and encapsulate complex logic behind a simple uses declaration.

Getting Started: Creating Your First Workflow

To create a workflow, add a YAML file to .github/workflows/ in your repository. The filename can be anything descriptive—ci.yml, deploy.yml, or nightly-scan.yml are common choices. GitHub automatically registers and displays all workflows found in this directory.

Below is a minimal CI workflow that runs tests on every push and pull request to the main branch:

name: CI
# Trigger the workflow on push and pull_request events
# targeting the main branch
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    # Use the latest Ubuntu runner
    runs-on: ubuntu-latest
    
    steps:
      # Step 1: Check out repository code
      - name: Checkout code
        uses: actions/checkout@v4
      
      # Step 2: Set up Node.js environment
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      # Step 3: Install dependencies
      - name: Install dependencies
        run: npm ci
      
      # Step 4: Run the test suite
      - name: Run tests
        run: npm test

Let's break down each section:

Workflow Configuration in Depth

Triggering with Events

The on key is the heart of workflow configuration. It determines when your workflow runs. You can specify a single event or a list of events, and you can further refine behavior with activity types and branch filters.

Here is a workflow triggered by multiple events with branch filters:

on:
  push:
    branches:
      - main
      - 'release/**'
    paths:
      - 'src/**'
      - 'package.json'
  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]
  schedule:
    - cron: '0 2 * * 1'  # Runs at 2 AM every Monday
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deployment target'
        required: true
        type: choice
        options:
          - staging
          - production

Notice the paths filter on push events—the workflow only triggers when files in src/ or package.json change, reducing unnecessary runs. The schedule event uses cron syntax for recurring execution. The workflow_dispatch event enables manual triggering with configurable inputs via the GitHub UI.

Conditional Execution with if

Steps and jobs can use the if conditional to control execution based on expressions. This is powerful for skipping steps in certain contexts:

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - name: Deploy
        run: ./deploy.sh

You can also conditionally skip steps using expression syntax:

steps:
  - name: Run e2e tests
    if: github.event_name != 'schedule'
    run: npm run test:e2e

Concurrency Control

Use concurrency to prevent multiple workflow runs from executing simultaneously for the same group. This is essential for deployment workflows to avoid race conditions:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

The cancel-in-progress flag automatically cancels any previously running workflow in the same concurrency group when a new run starts.

Jobs and Steps Configuration

Job Dependencies and Parallelism

By default, all jobs run in parallel. Use needs to define dependencies and create sequential execution pipelines:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: npm run lint
  
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test
  
  build:
    needs: [lint, test]  # Waits for lint and test to complete
    runs-on: ubuntu-latest
    steps:
      - run: npm run build
  
  deploy:
    needs: build  # Only runs after successful build
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

This creates a pipeline: lint and test run in parallel, build waits for both, and deploy waits for build.

Step Types and Action References

Steps can reference actions using several syntaxes:

steps:
  # Public action from marketplace (owner/repo@version)
  - uses: actions/checkout@v4
  
  # Action from same repository (relative path)
  - uses: ./.github/actions/my-custom-action
  
  # Action from a Docker image
  - uses: docker://ubuntu:22.04
  
  # Action pinned to a specific commit SHA
  - uses: actions/setup-node@2a5b3c4d9e8f1a2b3c4d5e6f7a8b9c0d1e2f3a4b

For shell steps, you can specify a custom working directory and shell:

steps:
  - name: Run build script
    run: ./build.sh
    working-directory: ./src
    shell: bash
  
  - name: Run Python script
    run: python main.py
    shell: python
    env:
      API_KEY: ${{ secrets.API_KEY }}

Job-Level Defaults and Conditionals

You can set defaults that apply to all run steps within a job:

jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
        working-directory: ./app
    steps:
      - run: echo "This runs in ./app"
      - run: npm install  # Also runs in ./app

Environment Variables and Secrets

Defining Environment Variables

Environment variables can be defined at the workflow level, job level, or step level. They are available to all run commands and actions:

name: Build with Env Vars

env:
  WORKFLOW_VAR: "Available to all jobs"

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      JOB_VAR: "Available to all steps in this job"
      NODE_ENV: production
    steps:
      - name: Show variables
        env:
          STEP_VAR: "Only this step"
        run: |
          echo "Workflow: $WORKFLOW_VAR"
          echo "Job: $JOB_VAR"
          echo "Step: $STEP_VAR"

Working with Secrets

Secrets are encrypted variables stored in your repository or organization settings. They are never displayed in logs and are accessed via the secrets context:

steps:
  - name: Deploy to production
    env:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    run: |
      echo "Deploying with secure credentials..."
      # Secrets are masked in logs if accidentally printed

To set secrets, navigate to your repository's Settings > Secrets and variables > Actions and add them there. Organization secrets are managed similarly at the organization level.

Using Contexts and Expressions

GitHub Actions provides rich contexts—objects containing information about the current workflow run, runner, job, and repository. Contexts are accessed with ${{ }} expression syntax:

steps:
  - name: Debug context information
    run: |
      echo "Repository: ${{ github.repository }}"
      echo "Branch: ${{ github.ref }}"
      echo "Commit SHA: ${{ github.sha }}"
      echo "Runner OS: ${{ runner.os }}"
      echo "Job ID: ${{ job.status }}"

Common contexts include:

Matrix Strategies

A matrix strategy allows you to run a job multiple times with different configurations—testing against multiple Node.js versions, operating systems, or any parameter combination. This is defined with the strategy.matrix key:

jobs:
  test:
    strategy:
      matrix:
        node-version: [16, 18, 20]
        os: [ubuntu-latest, windows-latest, macos-latest]
        include:
          - node-version: 21
            os: ubuntu-latest
            experimental: true
        exclude:
          - node-version: 16
            os: windows-latest
      fail-fast: false  # Continue other matrix jobs even if one fails
      max-parallel: 4   # Limit concurrent matrix jobs
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

The include key adds custom combinations or additional variables. The exclude key removes unwanted combinations. The fail-fast option (default true) determines whether all matrix jobs are cancelled when one fails. Each matrix combination spawns a separate job instance, running in parallel.

Caching and Artifacts

Dependency Caching

Caching dramatically speeds up workflows by storing dependencies between runs. The built-in cache action handles this elegantly:

steps:
  - uses: actions/checkout@v4
  
  - name: Cache Node.js modules
    uses: actions/cache@v4
    with:
      path: ~/.npm
      key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
      restore-keys: |
        ${{ runner.os }}-node-
  
  - name: Install dependencies
    run: npm ci

The cache key is critical—it should uniquely identify the dependencies. Use hashFiles() to generate a hash of your lock file, ensuring the cache is invalidated when dependencies change. restore-keys provides fallback partial matches when an exact key isn't found.

For language-specific setups, many setup actions have built-in caching:

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

Workflow Artifacts

Artifacts allow you to persist data between jobs in a workflow. Use upload-artifact and download-artifact actions:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - name: Upload build output
        uses: actions/upload-artifact@v4
        with:
          name: dist-files
          path: dist/
  
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: dist-files
          path: dist/
      - name: Deploy
        run: ./deploy.sh

Artifacts are automatically available across jobs that use the needs dependency. They expire after a configurable retention period (default 90 days).

Docker and Service Containers

Running Jobs in Containers

Instead of running directly on the runner VM, you can execute an entire job inside a Docker container:

jobs:
  container-test:
    runs-on: ubuntu-latest
    container:
      image: node:20-alpine
      env:
        NODE_ENV: test
      ports:
        - 80:80
      volumes:
        - ${{ github.workspace }}:/workspace
    steps:
      - uses: actions/checkout@v4
      - run: npm test
        working-directory: /workspace

The container key specifies a Docker image to use as the execution environment. All steps run inside this container, giving you complete control over the runtime environment.

Service Containers

Service containers run alongside your job container, providing auxiliary services like databases, caches, or message brokers during testing:

jobs:
  integration-test:
    runs-on: ubuntu-latest
    container:
      image: node:20
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run test:integration
        env:
          DATABASE_URL: postgresql://testuser:testpass@postgres:5432/testdb
          REDIS_URL: redis://redis:6379

Services are network-accessible via their label name (e.g., postgres, redis). The health check options ensure the service is ready before your steps begin executing.

Reusable Workflows and Composite Actions

Reusable Workflows

Reusable workflows allow you to call an entire workflow from another workflow, passing inputs and receiving outputs. This promotes DRY principles across repositories:

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

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      version:
        required: false
        type: string
        default: 'latest'
    secrets:
      DEPLOY_KEY:
        required: true
    outputs:
      deploy-url:
        value: ${{ jobs.deploy.outputs.url }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - name: Deploy application
        id: deploy
        env:
          KEY: ${{ secrets.DEPLOY_KEY }}
        run: |
          echo "Deploying to ${{ inputs.environment }} version ${{ inputs.version }}"
          echo "url=https://${{ inputs.environment }}.example.com" >> $GITHUB_OUTPUT

Calling this reusable workflow from another workflow:

jobs:
  call-deploy:
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
      version: '1.2.3'
    secrets:
      DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}

Composite Actions

Composite actions bundle multiple steps into a single action defined in your repository:

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Checkout, install Node, and install dependencies'
inputs:
  node-version:
    description: 'Node.js version'
    required: true
    default: '20'
runs:
  using: 'composite'
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'
    - name: Install dependencies
      run: npm ci
      shell: bash

Using the composite action in a workflow:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: ./.github/actions/setup-project
        with:
          node-version: '20'
      - run: npm run build

Best Practices

1. Pin Action Versions

Always pin actions to a specific version tag or commit SHA to ensure reproducibility and security:

# Good — pinned to major version tag
- uses: actions/checkout@v4

# Better — pinned to exact commit SHA for maximum security
- uses: actions/checkout@b4ffde65a46336ab88eb53be8084795b6b6a9c6e

2. Keep Workflows Minimal and Focused

Each workflow should handle one logical concern. Instead of one monolithic workflow, create separate workflows for CI, deployment, security scanning, and scheduled tasks. This improves debuggability and reduces blast radius when a workflow fails.

3. Use Concurrency for Deployment Workflows

Always add concurrency groups to deployment workflows to prevent race conditions when multiple pushes occur in quick succession:

concurrency:
  group: production-deployment
  cancel-in-progress: false  # Wait for current deployment to finish

4. Leverage Caching Aggressively

Cache dependencies, build outputs, and any expensive computations. Cache keys should be deterministic and include relevant hash files:

key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

5. Never Log Secrets

GitHub automatically masks secrets in logs, but only if they appear exactly as stored. Avoid printing secrets to logs, even for debugging. Use add::debug:: syntax if you need debug output that won't appear in normal logs.

6. Use Environment-Level Secrets

For deployment workflows targeting specific environments (production, staging), use GitHub's environment feature with required reviewers and environment-specific secrets:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: ./deploy.sh

Configure environments in Settings > Environments with protection rules and deployment approval requirements.

7. Validate Workflows Locally

Use tools like act to test workflows locally before pushing. The GitHub Actions extension for VS Code provides syntax validation and IntelliSense. Always lint your YAML—indentation errors are the most common source of workflow failures.

8. Set Appropriate Permissions

By default, workflows get a generous GITHUB_TOKEN with broad permissions. Restrict permissions explicitly:

permissions:
  contents: read
  issues: write
  pull-requests: write

This follows the principle of least privilege and reduces security risks from compromised actions.

Advanced Configuration Examples

Complete CI/CD Pipeline

Here is a comprehensive example that combines many concepts into a single workflow—CI testing on push, deployment on merge to main, with matrix testing, caching, and environment protection:

name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    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

  test:
    needs: lint
    strategy:
      matrix:
        node-version: [18, 20, 21]
        os: [ubuntu-latest]
        include:
          - node-version: 20
            os: windows-latest
      fail-fast: false
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm run test:unit
      - run: npm run test:coverage
      - name: Upload coverage report
        if: matrix.os == 'ubuntu-latest' && matrix.node-version == 20
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  build:
    needs: 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 build
      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

  deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/
      - name: Deploy to staging
        env:
          DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
        run: |
          echo "Deploying to staging environment..."
          # Actual deployment commands here

  deploy-production:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/
      - name: Verify deployment readiness
        run: |
          echo "Verifying production deployment prerequisites..."
          # Pre-deployment checks
      - name: Deploy to production
        env:
          DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}
        run: |
          echo "Deploying to production..."
          # Production deployment commands
      - name: Post-deployment smoke tests
        run: |
          curl -f https://app.example.com/health || exit 1
          echo "Smoke tests passed"

Scheduled Security Scanning

A workflow for nightly dependency vulnerability scanning:

name: Nightly Security Scan

on:
  schedule:
    - cron: '0 3 * * *'  # 3 AM UTC daily
  workflow_dispatch:

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - name: Run npm audit
        run: npm audit --audit-level=high
        continue-on-error: true
      - name: Run dependency scanner
        uses: github/dependabot-review-action@v1
      - name: Notify on vulnerabilities
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Security vulnerabilities detected in nightly scan for ${{ github.repository }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Cross-Platform Build with Service Containers

An integration testing workflow using PostgreSQL and Redis service containers across multiple platforms:

name: Integration Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  integration:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        node-version: [18, 20]
      fail-fast: false
    runs-on: ${{ matrix.os }}
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: runner
          POSTGRES_PASSWORD: runnerpass
          POSTGRES_DB: integration_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - name: Wait for services
        run: |
          for i in {1..30}; do
            pg_isready -h localhost -p 5432 && break
            echo "Waiting for PostgreSQL..."
            sleep 1
          done
      - name: Run migrations
        run: npm run db:migrate
        env:
          DATABASE_URL: postgresql://runner:runnerpass@localhost:5432/integration_test
      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://runner:runnerpass@localhost:5432/integration_test
          REDIS_URL: redis://localhost:6379
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name:

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles