GitHub Actions cost optimization: how to cut your CI bill
Reduce GitHub Actions costs with runner right-sizing, caching, concurrency controls, workflow audits, spend alerts and a lower per-minute rate.
Eddie Wang
Updated 14 min read
Most GitHub Actions bills grow for the same few reasons: every job runs on the default runner whether it needs to or not, caches miss, stale runs keep going after a new push, and nobody watches the spend until the invoice arrives.
The sections below go roughly in order of effort. They start with how GitHub prices minutes and when a bigger runner is cheaper, then cover caching, concurrency controls, a workflow audit and spend alerts, and end with what changes when you move jobs to a runner provider with a lower per-minute rate. Most sections include numbers or YAML you can copy.
How GitHub Actions pricing works
GitHub bills Actions compute per minute, and rounds each job up to the nearest whole minute. Standard runners are free in public repositories. Private repositories get a monthly allowance by plan: 2,000 minutes on Free, 3,000 on Pro and Team, and 50,000 on Enterprise Cloud (GitHub Actions billing). Past the allowance, the rate depends on the runner.
Standard runners
GitHub's per-minute rates for standard hosted runners, as of January 2026:
| Runner | Rate | Compared with Linux x64 |
|---|---|---|
| Linux 2-core (x64) | $0.006/min | baseline |
| Linux 2-core (arm64) | $0.005/min | about 17% less |
| Windows 2-core | $0.010/min | 67% more |
| macOS 3-core or 4-core | $0.062/min | just over 10x |
The Linux x64 runner is ubuntu-latest, and it's where most spend concentrates. At these rates, 10,000 minutes a month on default Linux runners costs $60, and the same minutes on macOS cost $620.
Larger runners
Larger runners (Team and Enterprise Cloud plans) scale roughly linearly with core count. Linux x64:
- 4-core: $0.012/min
- 8-core: $0.022/min
- 16-core: $0.042/min
- 32-core: $0.082/min
- 64-core: $0.162/min
arm64 is cheaper at every size: a 4-core arm64 runner is $0.008/min against $0.012/min for x64, a third less, if your workload builds on ARM.
Larger runners never draw from your included minutes. Every minute is billed, including in public repositories.
Runner right-sizing
The default 2-core runner is the right choice for many jobs, but not all of them, and the only way to know is to measure.
A 4-core runner costs twice as much per minute ($0.012 against $0.006). If your build is CPU-bound and parallelizes well, it might finish in 55% of the time. You'd pay 2x the rate for 55% of the minutes: 10% more money for a build that's 45% faster. Whether that's worth it depends on how much the feedback loop matters to you.
For single-threaded jobs (most linting, small test suites, deploy scripts), the extra cores sit idle and you pay double for nothing.
When a larger runner costs less
A bigger runner reduces total cost when the speedup is larger than the price multiplier. That tends to happen with:
- Parallel test suites that use every core, such as Jest workers, pytest-xdist, or
go testwith parallel packages. - Large compilations (Rust, C++, big TypeScript projects) where the compiler parallelizes.
- Docker builds with multi-stage Dockerfiles whose stages can build in parallel.
- Memory-bound jobs that run out of memory on the 2-core runner's 8 GB and need the 16 GB of a 4-core larger runner (runner specs).
To measure, run the same job on both sizes for a week, add up the billed minutes for each, and multiply by the per-minute rate. If the larger runner's total is lower, switch.
The macOS premium
macOS runners cost $0.062/min for 3 or 4 cores, $0.077/min for the 12-core runner and $0.102/min for the 5-core M2 Pro, which is roughly 10 to 17 times the 2-core Linux rate.
macOS is worth it when you need Xcode, run UI tests on Apple platforms, or sign and notarize macOS binaries. For everything else, build and test on Linux, and keep the macOS-only steps in a small job that takes two minutes instead of twenty.
Caching
Every minute a job spends downloading dependencies or rebuilding unchanged artifacts is billed, and a few lines of cache config can remove most of those minutes.
Get the cache key right
The most common mistake is keying only on the lockfile hash. That breaks when a matrix builds several OS or runtime versions from the same lockfile and each produces different native binaries. Put the runner OS and runtime version in the key.
GitHub doesn't report cache hit rates. To track yours, log the cache-hit output of actions/cache, which is true only on an exact key match.
What to cache
In rough order of value:
- Package manager caches for npm, Yarn, pip or Go modules. Cache the package manager's global cache directory rather than
node_modules;actions/setup-nodedoes this for you withcache: npm. - Build outputs such as Rust's
target/, the Gradle build cache or other compiled output. These save the most time per hit, but they grow fast and eat into the per-repository limit. - Docker layers, using Buildx's GitHub Actions cache backend (
type=gha) withdocker/build-push-action.
Restore keys
restore-keys is the fallback list actions/cache tries when the exact key misses, matched by prefix. For a Node project:
- uses: actions/cache@v6
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
When the lockfile changes, the prefix match restores the previous cache and npm downloads only what's new. Without restore-keys, every lockfile change starts from an empty cache.
Cache size limits
Each repository gets 10 GB of cache storage. You can raise a repository's limit and pay $0.07 per GB-month for the extra (storage pricing). Entries not accessed for 7 days are removed, and when a repository is over its limit, older entries are evicted first.
15 matrix combinations that each save a 600 MB cache add up to 9 GB, and one more cache pushes you into constant eviction. Check usage with gh api repos/OWNER/REPO/actions/cache/usage, then consolidate matrix keys or cache less.
Concurrency controls
A developer pushes to a branch, CI starts, and 30 seconds later they push a fix. Now two runs are billing minutes for the same branch, and the first one is already stale. It runs to completion unless you cancel it.
Cancel stale runs
Add a concurrency group to the workflow and set cancel-in-progress: true:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This groups runs by workflow and branch. When a new run starts in the same group, GitHub cancels the one in progress.
Don't set cancel-in-progress: true on deployments to production. A new deploy should wait in the queue behind the current one instead of cancelling it.
Designing the group key
The group key decides what gets cancelled:
${{ github.workflow }}-${{ github.ref }}groups by workflow and branch, which makes it a good default.${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}groups by pull request number when there is one. It works better for workflows triggered by bothpushandpull_request.deploy-${{ github.ref }}withcancel-in-progress: falsefor deployments, so a new run waits for the current one instead of cancelling it. GitHub keeps only the newest pending run per group, so the queue is one deep.
The workflow audit
Before changing runner sizes or caches, open your workflow files and look at what you actually run. Triggers, matrix size, timeouts and duplicated jobs are the usual places to find waste.
Triggers
- The full test suite runs on every push to every branch, when pull requests and
mainwould do. - CI runs on documentation-only changes. Skip them with a path filter:
paths-ignore: ['docs/**', '**/*.md']. - Deployment checks run on feature branches that never deploy.
Matrix size
A matrix multiplies your job count. Three Node versions, three operating systems and two package managers make 18 jobs per run, and many projects don't support all 18 combinations in production.
If yours doesn't, run the full matrix nightly or weekly, and on pull requests run the main combination plus one or two edge cases.
Timeouts
The default job timeout is 360 minutes. A hung integration test bills six hours before GitHub kills it. Set an explicit timeout on every job:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
Two to three times the normal duration works well: if tests take 5 minutes, a 15-minute timeout catches hangs without failing slow but healthy runs.
Duplicate work
As a repository ages, it's common to find two or three workflows that each run npm install and npm run build on their own. Merge them into one workflow whose jobs share artifacts, or move the shared setup into a reusable workflow.
Where Tenki Runners change the math
Everything above works within GitHub's rates. The rate itself changes when you move jobs to another runner provider.
Tenki Runners cost $0.002 per core-minute for x64 Linux (pricing). At the same core count:
| Runner | GitHub | Tenki | Difference |
|---|---|---|---|
| 2-core Linux | $0.006/min | $0.004/min | 33% less |
| 4-core Linux | $0.012/min | $0.008/min | 33% less |
| 8-core Linux | $0.022/min | $0.016/min | 27% less |
| 16-core Linux | $0.042/min | $0.032/min | 24% less |
Tenki also bills per second of runtime (limits and billing) instead of rounding each job up to a whole minute, which matters when you run many short jobs.
If your builds run on ARM, compare against GitHub's arm64 rates too: at 4 cores and up they match or undercut Tenki's x64 rate per minute.
Faster jobs bill fewer minutes
Tenki runs on bare-metal 5th Gen AMD Ryzen servers, so jobs often finish sooner, and fewer minutes compound with the lower rate. In our benchmarks, the full n8n monorepo CI took 55m 58s ($0.336) on GitHub's 2-core ubuntu-latest and 29m 15s ($0.234) on Tenki's 4-core runner: 48% faster and 30% cheaper, on twice the cores. Those are medians of three warm-cache runs with identical YAML apart from runs-on.
The "up to 60% cheaper" figure on our site comes from the same kind of comparison, where the speedup is largest: the chainwayxyz/citrea workflow went from 37m 3s ($0.222) on GitHub to 12m 12s ($0.098) on Tenki, 56% less (results). How much you save beyond the rate difference depends on how much faster your workload runs, so measure your own.
Plan fees and a worked example
Tenki's free Starter plan includes $100 in free credits, 5 concurrent jobs, and runners up to 2 vCPU / 4 GB (4 vCPU / 8 GB once a card is on file). The Team plan is $250/month, or $200/month billed yearly, with $100 in monthly credits, 100 concurrent jobs, and runners up to 16 vCPU / 32 GB.
Say you spend $500 a month on 2-core and 4-core Linux minutes on GitHub. On Tenki the same minutes cost about $333 before any speedup:
- On Starter, $333 a month, less the one-time $100 in free credits, if 5 concurrent jobs and 4-core runners are enough for you.
- On Team billed monthly, $250 + $333 − $100 = $483.
- On Team billed yearly, $200 + $333 − $100 = $433.
On Team, the rate difference alone covers the plan fee once your GitHub Linux spend passes about $450 a month (about $300 billed yearly), and the gap grows from there: at $3,000 a month on GitHub, Team billed monthly comes to about $2,150.
In December 2025 GitHub announced a $0.002/min platform charge on self-hosted runner minutes, then postponed it with no new date. Tenki jobs run as self-hosted runners from GitHub's point of view, so if the charge ever takes effect, a Tenki 2-core job would cost about what GitHub's own 2-core runner does ($0.004 + $0.002 = $0.006/min). Larger sizes would still come in under GitHub's rate, by less: a 4-core job would be $0.010/min against $0.012.
Trying it
You change the runs-on value to a Tenki label such as tenki-standard-medium-4c-8g (sizes). The Migration Wizard in the dashboard can open that pull request for you (quickstart). Actions, secrets, and actions/cache keep working; Tenki hosts a drop-in Actions cache backed by a 100 GiB (Starter) or 1,000 GiB (Team) workspace storage pool (caching).
If much of your CI spend comes from AI coding agents opening pull requests, the hidden CI tax of AI coding agents covers that case specifically.
Governance and spend tracking
New workflows and jobs get added all the time, so savings from the steps above wear off unless something is watching the spend.
Budgets
GitHub lets you set a budget on Actions and get alerts at 75%, 90% and 100% of it, and separately emails you when your included minutes hit 90% and 100% (budgets and alerts). A budget on a metered product like Actions can also stop usage once it's spent, with Stop usage when budget limit is reached. Without that option, you get an email and usage continues.
Set the budget about 20% above your expected monthly spend. That leaves room for release weeks and large pull requests while still catching a runaway workflow. On Tenki, a workspace can set an optional daily spending limit that cancels runs beyond it.
Metrics to track
Track these monthly:
- Billed minutes by runner type, which shows where the money goes.
- Average and p95 minutes per workflow run, to catch build-time regressions.
- Cache hit rate per repository, from the
cache-hitoutput you're logging. - The share of runs that were cancelled. If it's near zero on a busy repository, stale runs probably aren't being cancelled.
- Cost per merged pull request, meaning total Actions spend divided by pull requests merged. This is the number to watch over time, because it stays comparable as the team grows.
Alerting on spend spikes
GitHub's alerts fire at fixed thresholds. For a daily check, a scheduled workflow can read month-to-date spend from the billing usage report endpoint, GET /organizations/{org}/settings/billing/usage, and post to Slack when it crosses a line you choose. The endpoint is part of GitHub's enhanced billing platform. It returns usageItems rows with product, sku, quantity, unitType and netAmount (dollars after included usage and discounts), filtered by year, month and day.
The default GITHUB_TOKEN can't read org billing. Create a fine-grained token or GitHub App token with the organization Administration (read) permission and store it as a secret:
name: Actions spend alert
on:
schedule:
- cron: "0 9 * * 1-5"
workflow_dispatch:
jobs:
check-spend:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check month-to-date Actions spend
env:
GH_TOKEN: ${{ secrets.BILLING_TOKEN }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
ORG: your-org
ALERT_USD: "400"
run: |
SPEND=$(gh api "/organizations/$ORG/settings/billing/usage?year=$(date -u +%Y)&month=$(date -u +%-m)" \
--jq '[.usageItems[] | select(.product | ascii_downcase == "actions") | .netAmount] | add // 0 | . * 100 | round / 100')
echo "Month-to-date Actions spend: \$$SPEND"
if awk -v s="$SPEND" -v a="$ALERT_USD" 'BEGIN { exit !(s > a) }'; then
curl -sS -X POST "$SLACK_WEBHOOK" -H 'Content-Type: application/json' \
-d "{\"text\": \"GitHub Actions spend is \$$SPEND this month, over the \$$ALERT_USD alert line\"}"
fi
The script sums everything billed under the Actions product, including both minutes and storage. Because it runs every weekday morning, it flags a runaway workflow within a working day.
The playbook
Work through these in order:
- Add concurrency controls to every CI workflow. It takes three lines of YAML.
- Set job timeouts to cap the worst case for each job.
- Audit triggers and matrices, and cut jobs that shouldn't run.
- Fix cache keys and start logging hit rates.
- Right-size runners, measuring before you upgrade.
- Compare runner providers. Once the rest is done, the per-minute rate is the lever left, and Tenki's free Starter plan is enough to run your real workload and compare.
- Set up budgets and alerts so the bill doesn't creep back.
Steps 1 to 3 cost nothing and take an afternoon. Steps 4 and 5 need a week or two of measurement. Step 6 takes minutes to set up and a billing cycle to compare.


