Loading...
All Articles
CI/CD · 8 min read

Advanced GitHub Actions: Reusable Workflows, Matrix Builds, and Self-Hosted Runners

Go beyond basic CI/CD with advanced GitHub Actions patterns including reusable workflows, dynamic matrix strategies, self-hosted runners on Kubernetes, and cost optimization techniques for enterprise pipelines.

Beyond Basic GitHub Actions

Most teams start with GitHub Actions by copying a workflow from a blog post — build, test, deploy. It works, and that is fine for a single repository. But as your organization scales to dozens or hundreds of repos, you hit real problems:

  • Duplicated workflow files across every repository
  • Inconsistent CI/CD practices — each team invents their own patterns
  • Slow builds because every job runs sequentially on default runners
  • Escalating costs from GitHub-hosted runner minutes
  • Security gaps because secrets management is ad hoc

This guide covers advanced patterns that solve these problems at scale.

Step 1: Build Reusable Workflows

Reusable workflows are GitHub Actions' answer to DRY (Don't Repeat Yourself). You define a workflow once in a central repository and call it from any other repo.

Create a shared workflow in your .github organization repository:

# .github/workflows/docker-build-push.yml
# Organization: myorg/.github repository
name: Docker Build and Push

on:
  workflow_call:
    inputs:
      image_name:
        required: true
        type: string
        description: "Docker image name (e.g., myorg/payment-service)"
      dockerfile:
        required: false
        type: string
        default: "Dockerfile"
      context:
        required: false
        type: string
        default: "."
      platforms:
        required: false
        type: string
        default: "linux/amd64"
      push:
        required: false
        type: boolean
        default: true
    secrets:
      REGISTRY_USERNAME:
        required: true
      REGISTRY_PASSWORD:
        required: true
    outputs:
      image_digest:
        description: "The image digest"
        value: ${{ jobs.build.outputs.digest }}
      image_tag:
        description: "The image tag"
        value: ${{ jobs.build.outputs.tag }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.build-push.outputs.digest }}
      tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4

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

      - name: Login to Container Registry
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ inputs.image_name }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}

      - name: Build and Push
        id: build-push
        uses: docker/build-push-action@v5
        with:
          context: ${{ inputs.context }}
          file: ${{ inputs.dockerfile }}
          platforms: ${{ inputs.platforms }}
          push: ${{ inputs.push }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Generate SBOM
        uses: anchore/sbom-action@v0
        with:
          image: ${{ inputs.image_name }}@${{ steps.build-push.outputs.digest }}
          format: spdx-json
          output-file: sbom.spdx.json

      - name: Upload SBOM
        uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: sbom.spdx.json

Now any repository in your organization calls this with minimal configuration:

# payment-service/.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.22"
      - run: go test ./...

  build:
    needs: test
    uses: myorg/.github/.github/workflows/docker-build-push.yml@main
    with:
      image_name: myorg/payment-service
      platforms: "linux/amd64,linux/arm64"
    secrets:
      REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
      REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    uses: myorg/.github/.github/workflows/deploy-k8s.yml@main
    with:
      service: payment-service
      image_tag: ${{ needs.build.outputs.image_tag }}
      environment: production
    secrets: inherit

The calling workflow is clean, readable, and consistent across every service in your organization.

Step 2: Dynamic Matrix Strategies

Matrix builds let you test across multiple configurations in parallel. The real power comes from dynamic matrices generated at runtime.

Static Matrix Example

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        node-version: [18, 20, 22]
        exclude:
          - os: macos-latest
            node-version: 18
    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

Dynamic Matrix from Changed Files

This pattern runs tests only for services that changed — critical in monorepos:

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.changes.outputs.matrix }}
      has_changes: ${{ steps.changes.outputs.has_changes }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detect changed services
        id: changes
        run: |
          # Find which service directories have changes
          SERVICES=""
          for dir in services/*/; do
            service=$(basename "$dir")
            if git diff --name-only origin/main...HEAD | grep -q "^services/${service}/"; then
              SERVICES="${SERVICES}\"${service}\","
            fi
          done

          if [ -n "$SERVICES" ]; then
            # Remove trailing comma and build matrix JSON
            SERVICES="${SERVICES%,}"
            echo "matrix={\"service\":[${SERVICES}]}" >> "$GITHUB_OUTPUT"
            echo "has_changes=true" >> "$GITHUB_OUTPUT"
          else
            echo "has_changes=false" >> "$GITHUB_OUTPUT"
          fi

  test:
    needs: detect-changes
    if: needs.detect-changes.outputs.has_changes == 'true'
    strategy:
      matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: services/${{ matrix.service }}
    steps:
      - uses: actions/checkout@v4
      - run: make test
      - run: make lint

In a monorepo with 20 services, this means a change to the payment-service only tests that service, not all 20. Build times drop from 30 minutes to 3.

Step 3: Self-Hosted Runners on Kubernetes

GitHub-hosted runners are convenient but expensive at scale and limited in customization. Self-hosted runners on Kubernetes give you control over cost, performance, and security.

The Actions Runner Controller (ARC) is the official way to run self-hosted runners on Kubernetes:

# Install ARC using Helm
helm install arc \
  --namespace arc-systems \
  --create-namespace \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

# Create a GitHub App for authentication (more secure than PAT)
# Then configure the runner scale set
helm install arc-runner-set \
  --namespace arc-runners \
  --create-namespace \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
  --set githubConfigUrl="https://github.com/myorg" \
  --set githubConfigSecret.github_app_id="12345" \
  --set githubConfigSecret.github_app_installation_id="67890" \
  --set githubConfigSecret.github_app_private_key="$(cat private-key.pem)" \
  --set maxRunners=20 \
  --set minRunners=2

For fine-grained control, define runner scale sets with custom resources:

# runner-scale-set-values.yaml
githubConfigUrl: "https://github.com/myorg"

maxRunners: 20
minRunners: 2

containerMode:
  type: "kubernetes"
  kubernetesModeWorkVolumeClaim:
    accessModes: ["ReadWriteOnce"]
    storageClassName: "gp3"
    resources:
      requests:
        storage: 10Gi

template:
  spec:
    containers:
      - name: runner
        image: ghcr.io/actions/actions-runner:latest
        resources:
          limits:
            cpu: "4"
            memory: 8Gi
          requests:
            cpu: "2"
            memory: 4Gi
    nodeSelector:
      workload-type: ci-runners
    tolerations:
      - key: "ci-runners"
        operator: "Equal"
        value: "true"
        effect: "NoSchedule"

Cost Optimization with Spot Instances

Run your CI runners on spot/preemptible instances for 60-90% cost savings:

# eks-nodegroup-spot.yaml (for AWS EKS)
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
  name: ci-cluster
  region: us-east-1

managedNodeGroups:
  - name: ci-runners-spot
    instanceTypes:
      - m5.xlarge
      - m5a.xlarge
      - m6i.xlarge
    capacityType: SPOT
    minSize: 0
    maxSize: 30
    desiredCapacity: 2
    labels:
      workload-type: ci-runners
    taints:
      - key: ci-runners
        value: "true"
        effect: NoSchedule

CI workloads are ideal for spot instances because they are short-lived, stateless, and can tolerate interruptions (the job simply retries).

Step 4: Composite Actions for Shared Steps

While reusable workflows share entire pipelines, composite actions share individual steps. Use them for common patterns like setup, caching, and notification:

# .github/actions/setup-go-project/action.yml
name: "Setup Go Project"
description: "Checks out code, sets up Go, restores cache, and installs dependencies"

inputs:
  go-version:
    description: "Go version to install"
    required: false
    default: "1.22"

runs:
  using: "composite"
  steps:
    - uses: actions/checkout@v4

    - uses: actions/setup-go@v5
      with:
        go-version: ${{ inputs.go-version }}

    - name: Cache Go modules
      uses: actions/cache@v4
      with:
        path: |
          ~/go/pkg/mod
          ~/.cache/go-build
        key: ${{ runner.os }}-go-${{ inputs.go-version }}-${{ hashFiles('**/go.sum') }}
        restore-keys: |
          ${{ runner.os }}-go-${{ inputs.go-version }}-

    - name: Download dependencies
      shell: bash
      run: go mod download

    - name: Install tools
      shell: bash
      run: |
        go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
        go install gotest.tools/gotestsum@latest

Use it in any workflow:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: myorg/.github/.github/actions/setup-go-project@main
        with:
          go-version: "1.22"
      - run: gotestsum --format pkgname ./...
      - run: golangci-lint run

Step 5: Security Hardening

Advanced GitHub Actions security goes beyond basic secret management:

Pin Actions to SHA Hashes

# Bad: vulnerable to tag hijacking
- uses: actions/checkout@v4

# Good: pinned to exact commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Use Dependabot to keep pinned SHAs up to date:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      actions:
        patterns:
          - "*"

Restrict Workflow Permissions

Apply the principle of least privilege:

# At the workflow level
permissions:
  contents: read
  packages: write

# Or at the job level for more granularity
jobs:
  deploy:
    permissions:
      contents: read
      id-token: write  # For OIDC authentication
    steps:
      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1

OIDC Authentication (No More Long-Lived Secrets)

Replace static cloud credentials with OIDC federation:

# AWS: Create an IAM OIDC provider for GitHub Actions
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

This eliminates the need to store AWS access keys as GitHub secrets entirely.

Performance Tips

  • Use caching aggressivelyactions/cache for dependencies, Docker layer caching for builds
  • Run independent jobs in parallel — Do not chain jobs that have no real dependency
  • Use paths and paths-ignore triggers — Skip CI for documentation-only changes
  • Set concurrency groups — Cancel in-progress runs when new commits are pushed
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

on:
  push:
    branches: [main]
    paths-ignore:
      - "docs/**"
      - "*.md"
      - ".github/ISSUE_TEMPLATE/**"

Conclusion

GitHub Actions is far more powerful than most teams realize. By investing in reusable workflows, dynamic matrices, self-hosted runners, and proper security hardening, you transform ad hoc CI/CD into a scalable, cost-effective platform that serves your entire engineering organization.

At DevOpsVibe, we help teams design and implement GitHub Actions pipelines that scale from startup to enterprise. From migrating off Jenkins to building reusable workflow libraries to deploying self-hosted runners on Kubernetes, we bring battle-tested patterns that accelerate your development velocity. Talk to our CI/CD experts.

filed under
github-actionscicdautomationkuberneteskubernetesdevopspipelines
work with us

Want our team to help with your infrastructure?

talk to an engineerFree 30-min discovery callBook
close