GitHub Actions Patterns I Wish I'd Known Before Our CI Bill Tripled
Our CI pipeline hit 18 minutes per PR at one point, and the thing that finally forced me to fix it wasn't engineering pride, it was the monthly Actions bill landing in my inbox with a number that made me sit up straight. What follows is what actually moved the needle, in the order I applied them, because a couple of these compound on each other in ways that aren't obvious until you've done it.
Matrix builds, and the trap of overusing them
The first thing everyone reaches for is a build matrix, and it's genuinely useful when you actually need combinatorial coverage — testing against multiple Node versions, multiple OSes, multiple database versions.
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, macos-latest]
fail-fast: false
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
fail-fast: false is worth calling out specifically — the default is true, which cancels the whole matrix the moment one leg fails. That's fine for a quick smoke test but actively unhelpful when you're trying to diagnose which specific Node version broke, because you lose the results from the legs that were still running.
The trap is running the full matrix on every single push to every branch. We were doing 3 Node versions times 2 OSes times 4 test suites, which is 24 parallel jobs, on every commit to every feature branch, most of which nobody was about to merge that day. I moved to running the full matrix only on main and on PRs tagged ready-for-review, with feature branches getting a single Ubuntu/Node-20 leg for fast feedback:
strategy:
matrix:
node-version: ${{ github.ref == 'refs/heads/main' && fromJSON('[18,20,22]') || fromJSON('[20]') }}
That one conditional cut our monthly Actions minutes by close to 40% on its own, because most of our commit volume was work-in-progress pushes on feature branches that didn't need the full grid.
Reusable workflows instead of copy-pasted YAML
We had eleven repositories with nearly identical CI files — checkout, install, lint, test, build — each one drifting slightly out of sync because someone would fix a caching bug in one repo and forget to port it to the other ten. Reusable workflows fixed this properly, not with a workaround but with an actual first-class GitHub Actions feature.
The shared workflow lives in one repo:
.github/workflows/reusable-node-ci.yml
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20'
secrets:
NPM_TOKEN:
required: true
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm run lint
- run: npm test
And every consuming repo calls it in about six lines:
jobs:
ci:
uses: our-org/shared-workflows/.github/workflows/reusable-node-ci.yml@main
with:
node-version: '20'
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
The @main pin is convenient for iterating but a little dangerous for stability — a bad change to the shared workflow now breaks eleven repos at once instead of one. We eventually moved to pinning consumer repos to tagged releases of the shared workflow (@v3 rather than @main) once it stabilized, which gave us the DRY benefit without the blast radius of an untested change propagating everywhere simultaneously.
Dependency caching, done properly
Everyone caches node_modules or the equivalent, but a lot of caching setups I've seen are subtly wrong in ways that either don't help or actively cause stale-dependency bugs. The key is cache key specificity — you want a cache hit exactly when the lockfile hasn't changed, and a clean miss (not a stale partial hit) the moment it has.
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
The restore-keys fallback is a double-edged sword worth understanding — it lets you restore a partial match (an older cache for a different lockfile hash) as a starting point rather than starting from zero, which speeds up npm ci even on a cache miss. But it also means you shouldn't skip npm ci even when the cache restores; always let the install step run and reconcile, since a partial cache match is not a guarantee of correctness, only a speed optimization for the install step itself.
For Docker-based builds we layered in BuildKit's cache mounts on top of the Actions cache, which made a much bigger difference than I expected for our larger service:
- uses: docker/build-push-action@v5
with:
context: .
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
mode=max caches every layer rather than just the final image layers, which costs more cache storage but meant our Docker builds went from around 6 minutes to around 90 seconds on typical incremental changes.
Self-hosted runners, and when they're actually worth it
I want to be honest about this one: self-hosted runners are not free labor, they're a tradeoff, and I'd only recommend them once GitHub-hosted runner costs or limitations are a demonstrated problem, not a theoretical one. We moved to self-hosted for one specific workload — integration tests that needed a large amount of memory and ran against a local Postgres instance with a big seeded dataset — because the GitHub-hosted ubuntu-latest runners kept OOM-killing that job.
jobs:
integration-tests:
runs-on: [self-hosted, linux, x64, large-memory]
steps:
- uses: actions/checkout@v4
- run: docker compose up -d postgres
- run: npm run test:integration
The label-based targeting ([self-hosted, linux, x64, large-memory]) matters — tag your runners specifically enough that a workflow can't accidentally land on underpowered hardware. We run these on a small autoscaling group of EC2 spot instances using actions-runner-controller in Kubernetes rather than static VMs, specifically so idle runners scale to zero and we're not paying for compute sitting idle overnight. Setting this up is genuinely more operational overhead than checking a box on GitHub-hosted runners, and I wouldn't take it on unless a specific, measured pain point justifies it.
What actually moved the number
For anyone trying to reproduce the "18 minutes to under 6" result: the conditional matrix reduction and the reusable workflows were about ownership and cost efficiency more than raw speed. The two changes that actually cut wall-clock time on individual PR builds were the npm ci caching with correct lockfile-hash keys, and running lint, unit tests, and build as separate parallel jobs instead of sequential steps in one job — parallelizing across jobs (which run on separate runners simultaneously) rather than steps (which run sequentially on one runner) is the single highest-leverage change if your pipeline is currently one long linear job. If you only have time to do one thing from this post, split your monolithic CI job into parallel jobs by concern and let GitHub Actions run them concurrently; everything else here is refinement on top of that foundation.
Keeping it fast after the fact
The trap after any optimization project like this is assuming the work is done. Pipelines regress the same way codebases do — a new test suite added without parallelization in mind, a Docker layer reordered by someone unaware of the caching implications, a matrix quietly expanded again because a new browser target seemed important. We added a lightweight scheduled workflow that posts the p50 and p95 pipeline duration to a dashboard every week, specifically so a regression shows up as a visible trend rather than a slow accumulation nobody notices until someone complains in Slack. It caught exactly one real regression in the following six months, a newly added end-to-end suite that someone had wired up sequentially rather than as its own parallel job, and it got fixed within a day of landing instead of sitting there quietly costing everyone time for months the way our original problem did. Measuring once and calling it done is how pipelines end up slow again eighteen months later; measuring continuously is what actually keeps the fix from decaying.
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.