GitHub Actions: Advanced Patterns for Production CI/CD

Most GitHub Actions tutorials show the basics: checkout, install, test, done. That works for side projects. Production CI/CD needs more: reusable workflows, smart caching, secure secrets management, conditional deployments, and enough observability to debug failures quickly.

Here are the patterns worth knowing.

Reusable Workflows

When you have 10 repos with nearly identical CI configs, reusable workflows keep things DRY.

Define a reusable workflow (.github/workflows/ci-template.yml in a shared repo):

on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: "20"
    secrets:
      NPM_TOKEN:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

Call it from another repo:

jobs:
  ci:
    uses: myorg/shared-workflows/.github/workflows/ci-template.yml@main
    with:
      node-version: "22"
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Matrix Strategies

Test across multiple versions/platforms in parallel:

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    node: ["18", "20", "22"]
  fail-fast: false  # Don't cancel other matrix jobs on first failure

runs-on: ${{ matrix.os }}
steps:
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node }}

Dynamic matrices — generate the matrix from a script:

jobs:
  generate-matrix:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set.outputs.matrix }}
    steps:
      - id: set
        run: |
          echo "matrix=$(python scripts/get_changed_services.py)" >> $GITHUB_OUTPUT

  build:
    needs: generate-matrix
    strategy:
      matrix:
        service: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}

Dependency Caching

The actions/cache action (or the built-in cache option in setup actions) dramatically speeds up workflows.

- uses: actions/setup-node@v4
  with:
    node-version: "20"
    cache: npm         # Caches ~/.npm based on package-lock.json hash

- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
    cache: pip         # Caches pip based on requirements files

For Docker layer caching:

- uses: docker/setup-buildx-action@v3

- uses: docker/build-push-action@v6
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

This caches Docker layers in GitHub’s Actions cache, making rebuilds much faster when only app code changes.

OIDC: Secretless Cloud Authentication

Storing long-lived cloud credentials as GitHub secrets is a security antipattern. Use OpenID Connect (OIDC) instead — GitHub Actions gets short-lived tokens directly from your cloud provider.

AWS example:

permissions:
  id-token: write    # Required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
          aws-region: eu-west-1
      
      - run: aws s3 sync ./dist s3://my-bucket/

No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed. The IAM role trusts the GitHub OIDC provider, and credentials are scoped to the specific repo and branch.

Environments and Deployment Protection

Use GitHub Environments to add approval gates and scope secrets to specific stages:

jobs:
  deploy-prod:
    environment:
      name: production
      url: https://myapp.com
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh
        env:
          API_KEY: ${{ secrets.PROD_API_KEY }}  # Only available in production environment

In the GitHub UI, configure the production environment with:

  • Required reviewers — manual approval before the job runs
  • Wait timer — enforce a delay after staging deployments
  • Branch restrictions — only allow deployments from main

Concurrency Control

Prevent multiple deployments running simultaneously:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true  # Cancel older runs when a new one starts

For deployments where you want to queue rather than cancel:

concurrency:
  group: deploy-production
  cancel-in-progress: false  # Wait for the running deployment to finish

Workflow Triggers: Beyond push and pull_request

on:
  schedule:
    - cron: "0 6 * * 1"          # Every Monday at 6 AM UTC
  
  workflow_dispatch:              # Manual trigger with inputs
    inputs:
      environment:
        type: choice
        options: [staging, production]
        required: true

  pull_request:
    types: [opened, synchronize, reopened]
    paths:
      - "src/**"                  # Only trigger on src changes
      - "package.json"

  release:
    types: [published]            # Trigger on GitHub release creation

Job Dependencies and Outputs

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.build.outputs.tag }}
    steps:
      - id: build
        run: |
          TAG=$(git rev-parse --short HEAD)
          echo "tag=$TAG" >> $GITHUB_OUTPUT
          docker build -t myapp:$TAG .

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: kubectl set image deployment/myapp myapp=myapp:${{ needs.build.outputs.image-tag }}

  deploy-prod:
    needs: [build, deploy-staging]
    environment: production
    runs-on: ubuntu-latest
    steps:
      - run: kubectl set image deployment/myapp myapp=myapp:${{ needs.build.outputs.image-tag }}

Composite Actions

Package reusable steps into a composite action (can live in your repo or a dedicated action repo):

# .github/actions/setup-app/action.yml
name: Setup Application
inputs:
  node-version:
    default: "20"

runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: npm
    - run: npm ci
      shell: bash
    - run: npm run build
      shell: bash

Use it:

- uses: ./.github/actions/setup-app
  with:
    node-version: "22"

Debugging Tips

Enable debug logging by setting repository secrets:

  • ACTIONS_RUNNER_DEBUG=true
  • ACTIONS_STEP_DEBUG=true

Or add a tmate session for interactive debugging:

- uses: mxschmitt/action-tmate@v3
  if: failure()  # Only on failure

GitHub Actions is powerful enough to handle the most complex CI/CD scenarios. The patterns above — reusable workflows, OIDC auth, environment gates — are the difference between a hobby pipeline and a production-grade one.