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.
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:
- Never use self-hosted runners on public repositories, or limit them to specific protected workflows using environment protection rules.
- Scope IAM roles to the minimum required for each runner group. A build runner does not need deployment permissions.
- Use ephemeral runners (each job gets a fresh pod or instance, deleted after the job). ARC supports this with
ephemeral: trueon the runner spec. - 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
| Approach | Setup effort | Scalability | Operational burden | Cost model |
|---|---|---|---|---|
| GitHub-hosted runners | Minimal | Good up to ~$500/mo | None | Per minute, billed |
| Self-hosted EC2 (persistent) | Moderate | Manual scaling | Patching, AMI management | Instance hours |
| Self-hosted Kubernetes (ARC) | High | Excellent, reactive | Kubernetes ops knowledge | Node pool + Spot |
| Larger GitHub-hosted runners | None | Limited to available sizes | None | 2-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
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
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
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
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.