Published on

GitHub Actions in Production — Reusable Workflows, OIDC Auth, and Cutting Build Times

Authors

Introduction

GitHub Actions has evolved from a simple CI/CD tool into a comprehensive automation platform. Many teams still treat it as a black box—copying workflow files and hoping they work. In production environments, you need reproducibility, security, and speed. This post explores advanced patterns that power enterprise deployments: reusable workflows that enforce consistency across repositories, OIDC tokens that eliminate long-lived secrets, dynamic matrix builds that scale horizontally, and caching strategies that cut build times in half.

Reusable Workflows with workflow_call

Reusable workflows are the DRY principle applied to CI/CD. Instead of copy-pasting workflows across repos, define once and call from multiple places. This ensures all services follow the same quality gates.

Create .github/workflows/shared-build.yml in a central repository (e.g., org/shared-ci):

name: Build and Test
on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '20'
      run-e2e:
        required: false
        type: boolean
        default: false
    secrets:
      npm-token:
        required: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run type-check

      - name: Unit tests
        run: npm run test:unit -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json
          flags: unittests

      - name: Build
        run: npm run build

      - name: E2E tests
        if: ${{ inputs.run-e2e }}
        run: npm run test:e2e

      - name: Archive artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

Call from your service repository:

name: CI
on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  test:
    uses: org/shared-ci/.github/workflows/shared-build.yml@main
    with:
      node-version: '20'
      run-e2e: true
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

OIDC Authentication for AWS (Zero Secrets)

Store secrets in GitHub and you risk exposure. OIDC (OpenID Connect) tokens are short-lived and scoped to specific runs. GitHub Actions can mint these tokens and AWS exchanges them for temporary credentials.

Configure AWS OIDC provider:

# Run once in your AWS account
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

# Create role with trust policy
cat > trust-policy.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:org/service:*"
        }
      }
    }
  ]
}
EOF

aws iam create-role \
  --role-name github-actions-deploy \
  --assume-role-policy-document file://trust-policy.json

Use OIDC in your workflow:

name: Deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

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

      - name: Assume AWS role
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-deploy
          aws-region: us-east-1
          role-duration-seconds: 900

      - name: Deploy
        run: |
          aws s3 sync dist/ s3://my-bucket/
          aws cloudfront create-invalidation \
            --distribution-id E123ABC \
            --paths "/*"

Dynamic Matrix Builds from JSON

Run tests across multiple Node versions, databases, or configurations. Matrix builds discover test combinations from your repo metadata.

name: Test Matrix
on: [push, pull_request]

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4

      - id: set
        run: |
          MATRIX=$(jq -c '{
            "node-version": ["18", "20", "22"],
            "postgres-version": ["14", "15", "16"],
            "exclude": [
              {"node-version": "18", "postgres-version": "16"}
            ]
          }' < .github/matrix.json)
          echo "matrix=$(echo $MATRIX)" >> $GITHUB_OUTPUT

  test:
    needs: setup
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
    services:
      postgres:
        image: postgres:${{ matrix.postgres-version }}
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    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:integration
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/test

Caching Strategies

Caching is the fastest build—caching nothing is slower than any CI system. Layer your caches: dependencies, build output, and Docker images.

Node.js dependencies cache (built-in with setup-node):

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'
    cache-dependency-path: 'package-lock.json'

Docker layer caching with buildx:

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

- uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: my-registry/my-image:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

Environment Protection and Branch Protection

Enforce approvals before production deployments. Combine GitHub's environment protection with required status checks.

name: Deploy to Production
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://api.example.com
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: |
          echo "Deploying to production..."
          npm run deploy:prod

Configure in repository settings → Environments → production:

  • Required reviewers: select 2 team members
  • Deployment branches: allow only main
  • Protection rules: require branch protection

Concurrency Groups

Cancel outdated workflow runs automatically. When a new commit pushes, cancel previous runs on the same branch.

name: CI
on:
  push:
  pull_request:

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

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

Self-Hosted Runners for Private Resources

Cloud runners cost money. Self-hosted runners access internal databases, private NPM registries, and on-premises systems.

Register a runner in your infrastructure:

# On your machine
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.313.0.tar.gz \
  -L https://github.com/actions/runner/releases/download/v2.313.0/actions-runner-linux-x64-2.313.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.313.0.tar.gz
./config.sh --url https://github.com/org/repo --token YOUR_TOKEN --runnergroup "default" --labels "private,database"
./run.sh

Use in workflows:

jobs:
  integration-test:
    runs-on: [self-hosted, private, database]
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:db

Checklist

  • Audit all secrets in existing workflows; migrate to OIDC where possible
  • Extract common build steps into reusable workflows
  • Enable concurrency with cancel-in-progress for branch PRs
  • Set up dynamic matrix builds for dependency version testing
  • Configure environment protection for production deployments
  • Implement Docker layer caching with buildx
  • Enable branch protection requiring passing checks
  • Test self-hosted runner setup on internal network
  • Document runner installation and upgrade process
  • Monitor GitHub Actions usage and costs

Conclusion

GitHub Actions scales when you treat workflows as code, eliminate secrets through OIDC, and cache aggressively. Reusable workflows prevent drift across your organization. Dynamic matrices catch compatibility issues early. Self-hosted runners unlock access to private systems. Start by migrating one workflow to OIDC auth and adding concurrency groups—you'll see immediate benefits in build speed and security posture.