Most teams have no idea what their continuous integration actually costs, and the number only gets attention when finance asks about it.
The scale is enormous and the rules just changed. Developers burned through 11.5 billion GitHub Actions minutes in 2025 (GitHub, 2026), and in December 2025 GitHub overhauled its pricing. Hosted-runner prices dropped by up to 39 per cent from January 2026, while a new per-minute charge on self-hosted runners was announced and then postponed within days after a backlash (GitHub, 2026; Tenki, 2026). CI cost is now a live question, not a footnote.
Under per-minute billing, your bill is a direct function of total execution minutes, and most teams run far more minutes than they need. This post is about cutting that bill without slowing anyone down: measure it, cache it, only run what changed, cancel what is stale, and pick the right runner. Mostly it is about deleting minutes you were never getting value from.
Measure first, and put a ceiling on runaway jobs
You cannot cut a bill you have never read. GitHub gives you a usage report and a pricing calculator, and the first job is to pull your usage and find the handful of workflows responsible for most of the minutes. That is where every optimisation below will pay off most. The urgency is real: since the platform was re-architected it now handles 71 million jobs a day, and enterprises can start jobs 7 times faster than before, which means a misconfigured workflow can spin up thousands of jobs in seconds (GitHub, 2026).
The first guardrail costs nothing: a timeout, so a hung job stops instead of billing for six hours.
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15 # a hung job stops here, not after 6 billed hours
steps:
- uses: actions/checkout@v4
- run: ./run-tests.sh
Every job has a default six-hour limit, which is plenty of time for a stuck process to run up a real cost. Setting timeout-minutes to something close to your normal duration turns a runaway into a fast failure.
The trade-off is that a timeout set too tight will kill legitimate slow runs and create flaky failures. Set it comfortably above your 95th-percentile duration, not at the average, so only genuine hangs get caught.
Cache aggressively, with keys that actually hit
The single biggest source of wasted minutes is rebuilding and re-downloading things that have not changed. Most teams cache their dependencies and stop there, but the build output, compiled artifacts, and Docker layers are usually the larger cost. A well-configured cache with hash-based keys can cut 30 to 50 per cent off typical CI times (Tenki, 2026).
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm # caches the dependency download
# Cache the build output too, not just dependencies
- uses: actions/cache@v4
with:
path: .next/cache
key: build-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: build-${{ hashFiles('package-lock.json') }}-
The key includes a hash of the lockfile, so the cache invalidates only when dependencies actually change, and the commit SHA makes each build unique while the restore-keys line falls back to the most recent matching cache. That fallback is what lets a new commit reuse the previous build instead of starting from scratch.
The trade-off is that caching is not free and not foolproof. Cache storage has limits, and a key that is too broad serves stale data while a key that is too narrow never hits and quietly costs you the minutes anyway (Banandre, 2026). Key on the things that genuinely determine the output, and nothing else.
Only run what changed
This is the simplest optimisation, and the one most teams still skip. If your monorepo has ten services and a documentation-only change triggers all ten build pipelines, you are burning nine times the minutes for nothing (Tenki, 2026). Path filters tell a workflow to run only when files it cares about actually change.
on:
pull_request:
paths:
- "services/api/**" # only run when the API actually changes
- "!**/*.md" # never run for docs-only changes
This workflow now stays asleep unless something under the API service changes, and never wakes up for a docs edit. On a busy monorepo, that one block can remove the majority of the runs a workflow was doing, and the minutes with them.
The trade-off is that path filters can skip a job that should have run, for example when a shared library changes and your filter did not account for it. Scope the paths carefully, and be aware that a required status check combined with a path filter needs handling so a skipped job does not block the merge.
Cancel superseded runs before they finish billing
When you push three commits to a pull request in quick succession, by default GitHub runs the full pipeline for all three, even though only the last one matters. With the platform now able to start jobs far faster than before, those redundant and runaway runs are a real source of bill shock (Banandre, 2026). A concurrency group cancels the older run the moment a newer one starts.
# Cancel an older run on the same ref when a new commit lands
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
The group key combines the workflow and the branch, so a fresh push to the same pull request cancels the in-progress run and only the newest commit gets the full pipeline. Combined with path filters, this removes two of the biggest categories of waste at once: runs that did not need to start, and runs that no longer need to finish (Tenki, 2026).
The trade-off matters: never apply cancel-in-progress to a production deployment workflow. Cancelling a half-finished deploy can leave you in a worse state than letting it complete. Use cancellation for pull request and branch CI, and leave your deploys to run to the end.
Right-size the runner, and do the hosted-versus-self-hosted maths
Runner choice is where per-minute billing gets counterintuitive. Because you pay for time, a faster runner that costs more per minute can still be cheaper overall if it finishes proportionally sooner, so all else being equal you should prefer the faster machine (WarpBuild, 2025).
jobs:
build:
runs-on: ubuntu-latest # standard x64, the cheapest default
build-arm:
runs-on: ubuntu-24.04-arm # ARM, often cheaper per minute
heavy:
runs-on: ubuntu-latest-8-cores # a larger runner, only if it cuts minutes
Standard runners are the cheap default. ARM runners are frequently cheaper per minute for workloads that run on ARM. A larger runner is worth it only when it cuts your minutes roughly in proportion to its higher rate, never just because it feels faster. As for going self-hosted, the picture is in flux: GitHub cut hosted prices by up to 39 per cent (GitHub, 2026), and at scale self-hosted or third-party runners can still come out ahead, with some providers running roughly twice as fast (Northflank, 2026).
Two trade-offs to hold here. Self-hosting trades a per-minute fee for real operational burden, runner upkeep, scaling logic, and security, plus the per-minute platform charge GitHub has signalled it still intends to introduce (Tenki, 2026). And sharding a test suite across more runners cuts wall-clock time but not billable minutes, so it buys speed, not savings (Tenki, 2026). Optimise for total minutes, and decide on runners with a calculator, not a hunch.
The part worth sitting with
So pull the usage report you have probably never opened, and look at where the minutes actually go. Most of what you find will be waste you can delete without anyone noticing, except finance: the docs change that rebuilt ten services, the five superseded runs still grinding away after you pushed a fix, the dependency download repeated on every single job because nothing is cached. GitHub just made all of this visible by changing the price, and the teams that treat CI as something to measure and shape will quietly pay a fraction of what the teams who ignore it do. Your bill is not a fixed cost of doing business. It is a number you chose, one wasted minute at a time, and you can choose a smaller one this week.
Author note
I am Manjunaathaa, an Associate DevOps Engineer at Frigga Cloud Labs. I work across AWS, GCP, and Azure daily, with GitHub Actions as my deployment backbone. My focus is Proactive Resilience: keeping the cost and feedback loops on CI tight, so the pipeline bill is something I watch on a dashboard rather than discover on an invoice. Every practice in this post is something I actually run in production, not something I read about. I wrote this because CI spend is the budget line nobody owns until it doubles. The thing I keep coming back to is that under per-minute billing, speed and cost are the same problem: every minute of waste is a minute you pay for, so a good cache and a path filter save money and time at once. I run the cheap defaults first, measure before I reach for bigger runners, and only consider self-hosted or third-party runners once the maths actually beats the hosted price. Let's connect on LinkedIn → Manjunaathaa.
