A team was burning 40,000 CI minutes a month and could not say why. Here is how GitHub Actions billing actually works and where the money leaks.
A client called us in a mild panic last spring. Their GitHub bill had tripled in two months and nobody on the team could explain it. No new hires, no new repos, roughly the same commit volume. The culprit turned out to be one workflow that ran a macOS build matrix on every push to every branch. Nobody had looked at the multiplier. That one job was eating more minutes than the rest of the org combined.
This is the guide I wish that team had read a year earlier. How the billing model works, and then the handful of changes that actually move the number.
GitHub Actions gives you a monthly bucket of free minutes tied to your plan. Free accounts get 2,000 minutes a month, Pro gets 3,000, Team gets 3,000, and Enterprise gets 50,000. Private repos draw from that bucket. Public repos run on standard runners for free, which is a genuinely good deal and worth remembering when you decide what to open-source.
Once you burn through the bucket, you pay per minute. The base rate for a standard Linux runner is $0.008 per minute. That number looks trivial until you multiply it by a busy monorepo.
And multiply is the operative word, because the OS you run on carries a multiplier:
That macOS 10x is the line item that ends careers. A build that takes 12 minutes on Linux costs you the equivalent of 120 Linux minutes on a Mac runner. The free-minute bucket drains at 10x too, so a modest macOS test suite can exhaust a Pro plan's allowance before the month is half over.
Larger runners cost more on top of that. GitHub sells beefier machines (more vCPUs, more RAM, GPU options) at higher per-minute rates that scale roughly with the core count. A 16-core Linux runner is not 1x anymore. Those are billed at a separate published rate and never come out of the free bucket, so read the fine print before you reach for them to "speed things up."
Then there is storage and data transfer, which people forget entirely. Artifacts and GitHub Packages consume storage, billed per GB per month, against a small free allowance (500 MB on free plans, more on paid). Upload a 300 MB build artifact on every run with 90-day retention and you are quietly renting a small warehouse.
Self-hosted runners flip the model. You pay zero per-minute fees to GitHub; the minutes are free. What you pay for instead is the infrastructure underneath — the EC2 instance, the on-prem box, the maintenance, the security patching. For heavy sustained workloads that math often wins, but it is not free, it is just billed by someone else.
Now the optimization, ordered by how much I've seen each one save in practice.
Cache your dependencies. This is the single highest-leverage change. If every job reinstalls npm packages or Go modules from scratch, you are paying minutes to download the same tarballs forever. The actions/cache action, or the built-in caching in the setup actions, turns a three-minute install into fifteen seconds.
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
Stop redundant runs. Two moves here. Path filters so a docs-only commit does not trigger the full test suite, and concurrency with cancel-in-progress so a fresh push kills the now-pointless run from thirty seconds ago.
on:
push:
paths:
- 'src/**'
- 'package.json'
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
That cancel-in-progress line alone cut one team's minute usage by a fifth. They were force-pushing to feature branches constantly and every push spawned a full run that nobody waited for.
Have matrix discipline. A test matrix of 3 OSes by 4 language versions is 12 jobs. Do you actually support all twelve combinations, or did someone copy a template? Trim the matrix to what you ship. And if one leg fails fast and you want the rest to keep going, set fail-fast: false deliberately rather than paying for a full matrix that a single flake aborts.
Use Linux unless you have a reason not to. Given the multipliers, the default posture should be: everything runs on Linux, and Windows or macOS jobs are the exception you justify. If you ship a macOS app you obviously need Mac runners for the final build and notarization. You almost certainly do not need them for linting.
Move the heavy, frequent jobs to self-hosted runners. If you have a workload that runs constantly and eats hours, self-hosted runners can be dramatically cheaper than hosted minutes. Keep the occasional stuff on GitHub's hosted fleet where you do not want to babysit a machine.
Set artifact retention deliberately. The default retention is 90 days. Most CI artifacts are useless after a day. Drop it.
- uses: actions/upload-artifact@v4
with:
name: build
path: dist/
retention-days: 3
Factor common logic into reusable workflows. This is less about raw minutes and more about not maintaining eight slightly-different copies of the same pipeline, each with its own inefficiency. Centralize, fix the caching once, and every caller benefits.
Say you have a private repo on the Team plan (3,000 free minutes). Your CI runs 200 times a month.
The bad version: a 4-job matrix, two Linux and two macOS, each taking 10 minutes, no caching, running on every push including docs.
That is 44,000 minutes. Subtract the 3,000 free and you pay for 41,000 at $0.008, which is $328 a month for one repo's CI. The macOS legs are 40,000 of those minutes.
The fixed version: drop macOS to a single job that only runs on tags, add caching to shave each Linux job to 4 minutes, add path filters that cut runs to 140.
Total: 2,120 minutes, comfortably inside the free bucket. You now pay nothing. Same coverage on the things that matter.
Look at your billing page before you optimize anything else, because it tells you exactly where the minutes go and it is almost never where people guess. Nine times out of ten the answer is a macOS or Windows matrix leg that did not need to exist, or a workflow with no caching triggered on every trivial push. Fix those two things and most bills fall back to sane.
Beyond that: default to Linux, cache aggressively, kill redundant runs with concurrency, and only reach for self-hosted runners once you have a workload heavy enough to justify owning the machine. If you are still shopping around for a CI/CD platform and cost is a deciding factor, Actions is competitive as long as you respect the multipliers. Ignore them and it will quietly bleed you.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
One bundles your whole toolchain, the other lets you build anything from parts. The right pick depends on how much glue you want to own.
GitHub Actions OIDC gets all the attention, but GitLab, Buildkite, and CircleCI issue the same signed tokens. Here's how to trust them without opening a hole.
Explore more articles in this category
Every CI/CD platform claims to be fast and easy. The real differences are in pricing, self-hosting, and where each one falls apart at scale. This is the map.
Woodpecker forked Drone when the license changed. Here's how the two compare for small teams and homelabs that just want simple container-native CI.
Buildkite runs the control plane and lets you own the compute; Actions keeps everything close to your repo. Here's how they actually differ once you scale.
Evergreen posts worth revisiting.