DevOps ·

GitHub Actions at Scale: Reusable Workflows, Matrix Strategies, and Self-Hosted Runners for Monorepo CI/CD

A practical guide to scaling GitHub Actions beyond basic workflows. Covers reusable workflows, matrix strategies, self-hosted runner autoscaling on Kubernetes, monorepo path triggers, and cost optimization patterns for engineering teams managing large repositories.

GitHub Actions at Scale: Reusable Workflows, Matrix Strategies, and Self-Hosted Runners for Monorepo CI/CD

Basic GitHub Actions setups break down predictably. A monorepo with fifteen packages runs every test on every push, costing twelve minutes per PR and exhausting your GitHub-hosted runner budget by midmonth. Reusable workflows exist in flat files that everyone copies and modifies individually, so a security fix to the deploy step requires touching eight files. Self-hosted runners were set up manually on a VM that someone forgot to patch.

This article covers the patterns that hold up once the basic setup stops working: reusable workflow architecture, matrix strategies for parallel test coverage, self-hosted runner autoscaling on Kubernetes, monorepo-specific path triggers with dependency-aware build graphs, and cost controls that actually change your bill.

Reusable Workflows vs Composite Actions

GitHub gives you two reuse mechanisms. They solve different problems.

Composite actions bundle multiple steps into a single action. They run inside the calling workflow’s job, share its environment, and can use inputs and outputs. Use them to package a sequence of steps that would otherwise be copy-pasted across jobs.

Reusable workflow files are complete workflows called with uses. They run as separate jobs (or job graphs) with their own runner lifecycle, their own secrets context, and their own environment. Use them when you need to encapsulate an entire deployment pipeline, a shared testing pattern, or anything that needs its own runner.

The distinction that matters: composite actions cannot define their own triggers, cannot use strategy.matrix at the top level, and cannot be called with workflow_call inheritance. Reusable workflows can.

Composite Action Example

# .github/actions/setup-node/action.yml
name: Setup Node with Cache
description: Install Node, restore pnpm cache, install dependencies

inputs:
  node-version:
    description: Node.js version to use
    required: false
    default: "20"
  working-directory:
    description: Directory containing package.json
    required: false
    default: "."

runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}

    - uses: pnpm/action-setup@v3
      with:
        version: 9

    - name: Get pnpm store path
      id: pnpm-cache
      shell: bash
      run: echo "store=$(pnpm store path)" >> $GITHUB_OUTPUT

    - uses: actions/cache@v4
      with:
        path: ${{ steps.pnpm-cache.outputs.store }}
        key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
        restore-keys: |
          pnpm-${{ runner.os }}-

    - name: Install dependencies
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      run: pnpm install --frozen-lockfile

Reusable Workflow File

# .github/workflows/deploy-service.yml
name: Deploy Service

on:
  workflow_call:
    inputs:
      service:
        required: true
        type: string
      environment:
        required: true
        type: string
      image-tag:
        required: true
        type: string
    secrets:
      AWS_ROLE_ARN:
        required: true
    outputs:
      deployed-url:
        description: URL of the deployed service
        value: ${{ jobs.deploy.outputs.url }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Deploy to ECS
        id: deploy
        run: |
          aws ecs update-service \
            --cluster ${{ inputs.environment }}-cluster \
            --service ${{ inputs.service }} \
            --force-new-deployment
          echo "url=https://${{ inputs.service }}.${{ inputs.environment }}.example.com" >> $GITHUB_OUTPUT

Calling it from another workflow:

jobs:
  deploy-api:
    uses: ./.github/workflows/deploy-service.yml
    with:
      service: api
      environment: production
      image-tag: ${{ needs.build.outputs.image-tag }}
    secrets:
      AWS_ROLE_ARN: ${{ secrets.PROD_AWS_ROLE_ARN }}

The secrets: inherit shorthand passes all secrets to the reusable workflow. Use it in development environments where secret exposure is acceptable. In production, explicit secret mapping reduces the blast radius of a compromised workflow.

Matrix Strategies

Matrix strategies generate jobs dynamically. The common use case is testing across Node versions or operating systems, but they also work well for testing monorepo packages in parallel.

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        node: [18, 20, 22]
        os: [ubuntu-latest, macos-latest]
        exclude:
          - node: 18
            os: macos-latest
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node
        with:
          node-version: ${{ matrix.node }}
      - run: pnpm test

fail-fast: false prevents one failing matrix cell from cancelling others. For cross-version compatibility checks, you usually want all results before deciding how to respond. Set fail-fast: true only when a failure in one cell makes other cells meaningless.

Dynamic Matrix from Script Output

Static matrices work until the list grows. For monorepos, generate the matrix from the packages themselves:

// scripts/get-changed-packages.ts
import { execSync } from "child_process";
import { readdirSync, existsSync } from "fs";
import { join } from "path";

interface PackageInfo {
  name: string;
  path: string;
  hasTests: boolean;
}

function getChangedFiles(base: string): string[] {
  const output = execSync(`git diff --name-only ${base}...HEAD`).toString();
  return output.trim().split("\n").filter(Boolean);
}

function getAllPackages(): PackageInfo[] {
  const packagesDir = "packages";
  return readdirSync(packagesDir)
    .filter((dir) => existsSync(join(packagesDir, dir, "package.json")))
    .map((dir) => ({
      name: dir,
      path: join(packagesDir, dir),
      hasTests: existsSync(join(packagesDir, dir, "src/__tests__")),
    }));
}

function getAffectedPackages(changedFiles: string[]): PackageInfo[] {
  const allPackages = getAllPackages();
  return allPackages.filter((pkg) =>
    changedFiles.some((file) => file.startsWith(pkg.path))
  );
}

const base = process.argv[2] || "origin/main";
const changedFiles = getChangedFiles(base);
const affected = getAffectedPackages(changedFiles);

// Output consumed by the matrix step
console.log(JSON.stringify({ package: affected.map((p) => p.name) }));
jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: ./.github/actions/setup-node
      - id: set-matrix
        run: |
          MATRIX=$(npx ts-node scripts/get-changed-packages.ts origin/main)
          echo "matrix=$MATRIX" >> $GITHUB_OUTPUT

  test-packages:
    needs: detect-changes
    if: ${{ needs.detect-changes.outputs.matrix != '{"package":[]}' }}
    strategy:
      matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node
      - run: pnpm --filter ${{ matrix.package }} test

The if guard prevents the matrix job from failing when there are no changed packages. An empty matrix causes a workflow error without it.

Self-Hosted Runners

GitHub-hosted runners are convenient until you need: GPU workloads, access to private network resources, specific hardware, larger machine sizes, or more predictable cost at volume.

Architecture Decisions

Before autoscaling on Kubernetes, decide whether you need it. A fleet of persistent VMs behind Auto Scaling Groups on EC2 is simpler to operate and adequate for most teams. The Kubernetes path makes sense when you are already running Kubernetes and want to use existing node pools or Spot capacity.

The two Kubernetes-native approaches are:

  • actions-runner-controller (ARC): The official GitHub-maintained controller. Mature, well-documented, scales runner pods based on queued jobs.
  • Philips-labs/terraform-aws-github-runner: Manages EC2 instances rather than pods. Better for workloads that do not containerize cleanly (e.g., builds requiring Docker-in-Docker or nested virtualization).

For Kubernetes with ARC:

# values.yaml for actions-runner-controller Helm chart
githubWebhookServer:
  enabled: true

metrics:
  serviceMonitor:
    enabled: true

# RunnerDeployment for a standard build runner
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
  name: monorepo-runner-autoscaler
spec:
  scaleTargetRef:
    name: monorepo-runner
  minReplicas: 1
  maxReplicas: 20
  metrics:
    - type: TotalNumberOfQueuedAndInProgressWorkflowRuns
      repositoryNames:
        - your-org/your-monorepo
  scaleUpTriggers:
    - githubEvent:
        workflowJob: {}
      amount: 1
      duration: "10m"

Keep at least one warm replica. Cold starts on Kubernetes add 60-90 seconds before a runner registers, which shows up as queue time on every PR if you scale to zero.

Security Considerations

Self-hosted runners are not isolated by default. A workflow running on your runner can access IAM instance profiles, environment variables set on the host, and any mounted secrets. The threat model is: a malicious PR on a public repo triggers a workflow that exfiltrates your runner’s credentials.

Practical mitigations:

  1. Never use self-hosted runners on public repositories, or limit them to specific protected workflows using environment protection rules.
  2. Scope IAM roles to the minimum required for each runner group. A build runner does not need deployment permissions.
  3. Use ephemeral runners (each job gets a fresh pod or instance, deleted after the job). ARC supports this with ephemeral: true on the runner spec.
  4. Audit what environment variables are available on the runner. Strip anything the workflow does not need.

Monorepo Path-Based Triggers

The naive approach is triggering everything on every push. The correct approach is triggering based on what changed.

on:
  push:
    branches: [main]
  pull_request:
    paths:
      - "packages/api/**"
      - "packages/shared/**"
      - ".github/workflows/api-ci.yml"

The problem with path filters in GitHub Actions: if no files in the specified paths changed, the workflow is skipped entirely. This means required status checks may never appear on a PR, which blocks merging. Solve this with a separate “paths changed” job that always runs:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      api: ${{ steps.filter.outputs.api }}
      web: ${{ steps.filter.outputs.web }}
      shared: ${{ steps.filter.outputs.shared }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'packages/api/**'
              - 'packages/shared/**'
            web:
              - 'packages/web/**'
              - 'packages/shared/**'
            shared:
              - 'packages/shared/**'

  test-api:
    needs: changes
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node
      - run: pnpm --filter api test

  # Synthetic "all-tests-passed" job that required status checks point to
  ci-complete:
    needs: [test-api, test-web]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Check all jobs
        run: |
          if [[ "${{ needs.test-api.result }}" == "failure" || \
                "${{ needs.test-web.result }}" == "failure" ]]; then
            exit 1
          fi
          echo "All required checks passed or were skipped"

Point your branch protection required status check to ci-complete. It runs on every PR and succeeds when jobs are either passing or legitimately skipped, so you never have a stuck PR waiting for a check that will never fire.

Caching Strategies

Dependency Caching

pnpm’s global store is the right target for monorepos. One cache key covers all packages:

- name: Get pnpm store path
  id: pnpm-cache
  run: echo "store=$(pnpm store path)" >> $GITHUB_OUTPUT

- uses: actions/cache@v4
  with:
    path: ${{ steps.pnpm-cache.outputs.store }}
    key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

The restore-keys fallback matters: a partial cache hit on a stale lockfile still avoids a full cold install. Most packages will still be present.

Build Artifact Reuse

Build once per commit, reuse the artifact across all jobs that need it:

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      artifact-name: ${{ steps.upload.outputs.artifact-name }}
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node
      - run: pnpm build
      - id: upload
        uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: packages/*/dist
          retention-days: 1

  test-e2e:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: packages
      - run: pnpm test:e2e

Retention set to 1 day is appropriate for PR artifacts. Artifacts used in deployment pipelines should use longer retention (7-30 days) to support rollback.

Cost Optimization

Concurrency Limits

Without concurrency controls, every push to a feature branch launches a new workflow run while the previous one is still executing. On a busy team, this burns runner minutes on work that will be superseded.

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

For main branch workflows, do not cancel in progress. Cancelling a running deployment is worse than running two in sequence:

concurrency:
  group: deploy-production
  cancel-in-progress: false

Timeout Management

Default timeout is 6 hours. A hung step will silently consume runner minutes for hours without a timeout:

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Run tests
        timeout-minutes: 10
        run: pnpm test

Set both job-level and step-level timeouts. Job-level catches infinite loops in setup scripts; step-level catches a specific test suite hanging.

Runner Sizing

GitHub-hosted runners come in several sizes. The default ubuntu-latest is 2-core, 7GB RAM. For TypeScript compilation of large monorepos, a 4-core runner (billed at 2x) often halves build time, making the effective cost the same or better.

runs-on: ubuntu-latest-4-core

Measure before assuming larger is cheaper. Profile which steps are CPU-bound vs I/O-bound. Type-checking and bundling scale with cores. Test execution with a good parallelization strategy often scales better by splitting into matrix jobs than by adding cores.

Tradeoffs

ApproachSetup effortScalabilityOperational burdenCost model
GitHub-hosted runnersMinimalGood up to ~$500/moNonePer minute, billed
Self-hosted EC2 (persistent)ModerateManual scalingPatching, AMI managementInstance hours
Self-hosted Kubernetes (ARC)HighExcellent, reactiveKubernetes ops knowledgeNode pool + Spot
Larger GitHub-hosted runnersNoneLimited to available sizesNone2-10x per minute rate

Self-hosted Kubernetes is the right answer only if you already operate Kubernetes and have the team to maintain it. The operational surface (ARC version upgrades, runner image maintenance, node pool sizing) is real and ongoing.

Production Considerations

Cache poisoning: Cache keys derived from lockfiles are predictable. A dependency confusion attack could theoretically influence cache contents. Use CACHE_VERSION secrets to invalidate caches on demand, and verify critical dependency hashes in your lockfile.

Secret isolation: Workflows on feature branches can access the same secrets as main if you are not careful with environment protection rules. Create a production environment in GitHub Settings and gate deployment workflows behind it. Environment secrets are only available when a protection rule approves the deployment.

Runner registration: Self-hosted runners authenticate with a registration token that expires after one hour. ARC handles token refresh automatically. If you are registering runners manually or with a custom script, token expiry is a common source of mysterious “no runner available” errors.

Matrix job limits: GitHub limits concurrent matrix jobs to 256 per workflow. For monorepos with many packages, this is rarely hit, but the detect-changes approach above also keeps the matrix small by only testing affected packages.

Artifact storage: GitHub counts artifact storage against your account quota. Set retention-days explicitly on all artifact uploads. Defaulting to 90 days on a high-volume repo will exhaust your quota.

Closing

The patterns here compound. Reusable workflows standardize your deployment contract across services. Path-based triggers cut job volume by 60-80% in a typical monorepo. A ci-complete synthetic job unblocks PRs without requiring every workflow to always run. Self-hosted runners on Kubernetes pay off once your runner minutes bill exceeds the operational cost of maintaining the fleet.

The failure mode to avoid is cargo-culting configurations from other teams’ repos. Measure your actual build time breakdown before choosing where to invest: dependency install, compilation, test execution, and artifact upload each have different optimization levers, and spending two days configuring Turborepo remote caching on a repo where 80% of CI time is in test execution will not move your numbers.

Start with concurrency limits and timeouts (one hour, no infrastructure changes), then add path-based triggers, then address whatever the profiling shows is the actual bottleneck.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.