---
title: "How to cut your GitHub Actions bill"
description: "Five changes that reduce GitHub Actions spend, from the ones that cost nothing to make to the ones that need a different runner underneath."
url: https://9apes.com/blog/cut-your-github-actions-bill/
---

# How to cut your GitHub Actions bill

GitHub Actions bills by the minute, and the meter runs on every push. Most teams
notice it the month a bill crosses whatever number makes finance ask about it.
By then the workflows have usually accumulated a few years of habits that nobody
has revisited.

Here is the order I would work through, cheapest change first.

## 1. Stop running work that nothing depends on

The largest savings usually come from jobs that should not have run at all.

Path filters are the blunt instrument, and they work:

```yaml
on:
  pull_request:
    paths-ignore:
      - '**.md'
      - 'docs/**'
```

A documentation-only pull request should not compile anything. Neither should a
change to a package your test suite never imports. If you run a monorepo, the
matching problem is harder, but the principle holds: the cheapest minute is the
one you never bill.

Concurrency groups are the other easy win. Without one, pushing three commits in
quick succession runs three full builds, and you only care about the last:

```yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
```

On an active repository this alone can remove a double-digit percentage of total
minutes, and it costs one paragraph of YAML.

## 2. Fix the cache before you touch anything else

A cache that misses is worse than no cache: you pay to download nothing, then pay
again to rebuild. Cache misses are also easy to miss, because the job still
passes.

Check a recent run and look at the cache step. If it reports a miss on most runs,
the key is wrong. The usual cause is a key that includes something which changes
every run: a timestamp, a commit SHA, a generated lockfile. It can never hit.
Keys should change when the dependencies change, and not otherwise.

## 3. Right-size the runner

Bigger runners cost proportionally more per minute, so a 4 vCPU runner only saves
money if the job actually gets faster. For a job that spends its life waiting on a
network call, it will not.

The test is straightforward: run the same job on two sizes and compare wall-clock
time. If doubling the vCPU count does not come close to halving the duration, the
job is not CPU-bound and the larger runner is pure cost. Compile-heavy work
usually scales well. Integration tests waiting on a database usually do not.

## 4. Reconsider what the job runs on

Everything above is free and you should do it regardless. This one is a decision.

GitHub-hosted runners are priced per minute at published list rates that scale
with runner size. Third-party managed runners exist because that pricing left
room underneath it, and most of them migrate the same way: change one line.

```diff
 jobs:
   test:
-    runs-on: ubuntu-latest
+    runs-on: 9apes
```

Two things move your bill when you switch, and it is worth separating them,
because providers routinely conflate them into one impressive percentage.

The first is the **rate**: what a minute costs. That is a straight comparison of
published prices at the same runner size.

The second is **duration**: how many minutes the job bills for. A faster machine
finishes sooner, so the meter runs for less time. This one is invisible on a
pricing page and it is often the larger effect.

They compound. If a runner is half the price and the job finishes in half the
time, the bill for that job falls by roughly 75%, not 50%. That is why per-job
savings quoted by runner vendors look so much larger than the difference in their
rate cards, and why you should ask which one a given number refers to before
putting it in a budget.

Our own figures work exactly this way: the [rate card](/pricing/) is up to 70%
below GitHub-hosted list prices per minute, while measured cost per job on
public repositories like `google/xls` and `facebook/folly` lands closer to 89%.
Same product, two honest numbers, measuring different things.

## 5. Look at where the minutes actually go

Most teams estimate this wrong. Before optimising anything, sort your workflows
by total minutes consumed and look at the top three. It is almost never the job
people complain about. That one is merely the most *annoying*, which is a
different metric from the most *expensive*.

A nightly job nobody watches, running a full matrix across six versions, will
quietly outspend the pull request check that everyone notices.

## The order that matters

If you do nothing else: add a concurrency group, fix your cache keys, then check
whether your biggest job is CPU-bound. Those three are free, take an afternoon,
and routinely cut a bill by a third.

Changing runners is worth doing after that, not instead of it. A wasteful
workflow on a cheaper runner is still a wasteful workflow.

If you want to size the runner question for your own repository rather than
trusting anyone's benchmark, the [pricing page](/pricing/) has a calculator that
takes your monthly minutes, and [the comparisons](/compare/) list published rates
for the main providers with the date each was checked.
