Faster CI is Cheaper CI
Author
Spencer Dangel
Date Published

CI optimization usually gets weighed as a return-on-investment question: is the engineering time you'd sink into speeding things up worth what you'd actually save? On GitHub Actions there's a happier answer than usual, because speed and cost turn out to be mostly the same lever.
The reason is one detail in the billing: you pay per minute, rounded up to the whole minute. A job that takes 4 minutes and 10 seconds bills as 5 minutes. Get it down to 3:50 and you've cut a billable minute off every run, on every push, from now on. Across a team pushing all day, that adds up faster than you'd think.
It adds up even faster now that AI agents are in the loop. If Copilot, Claude, or an autonomous agent is opening PRs and pushing fix-up commits alongside your engineers, your run volume isn't growing with headcount anymore. Every minute you shave gets multiplied across a lot more runs than it used to, and every minute of pipeline latency now stalls agent loops as well as people.
Every technique here makes pipelines faster, and the billing model means faster = cheaper. Better still, the biggest wins are the easiest ones. Most are one or two lines of YAML instead of a weekend of infrastructure work.
This post starts with the free, low-risk changes that provide the biggest impact. Then we'll move to the murkier questions like whether you should ever run your own runners. First though, let's cover the bill itself, because none of the advice makes sense without it.
What you're actually paying for
GitHub actions are billed per minute, rounded up. This changes how you think about job design. A workflow split into ten 30-second jobs doesn't cost five minutes; it costs ten, because each job rounds up on its own. Sometimes fewer, larger jobs are cheaper than many small ones.
The operating system matters a lot. GitHub applies a multiplier based on the runner's OS (numbers based on July 2026):
Runner | Per-minute rate | Cost multiplier |
|---|---|---|
Linux (2-core) | $0.006 | 1× |
Windows (2-core) | $0.010 | 2× |
macOS (3-4 core) | $0.062 | 10× |
A macOS minute costs ten times a Linux minute. If you remember one thing from this post: run everything you can on Linux, and treat Windows minutes, and especially macOS minutes, as scarce.
Public repos are free. Private repos get a monthly allowance before overage kicks in:
Plan | Included minutes/month |
|---|---|
Free | 2,000 |
Pro | 3,000 |
Team | 3,000 |
Enterprise | 50,000 |
Those are Linux-equivalent minutes, they reset each billing cycle, and there's no rollover. A macOS job drains the bucket ten times faster than a Linux one.
The rule that falls out of all this: the cheapest minute is one you never run, and the second cheapest is a Linux minute.
Win 1: cache your dependencies
In almost every pipeline, the most repeated and most cacheable work is installing dependencies. Your application code changes constantly; your package-lock.json or NuGet packages change rarely. Without caching, every run downloads and installs the whole dependency tree from scratch anyway, paying for the same work over and over. Caching that step commonly cuts install time by 50-80%.
Built-in caching
For most languages you don't need a separate caching action at all. The official setup-* actions have caching built in, usually behind a single line. People miss this constantly.
Node and Expo:
1- uses: actions/setup-node@v62 with:3 node-version: 244 cache: 'yarn' # or 'npm' / 'pnpm'5 cache-dependency-path: '**/yarn.lock'6- run: yarn install --frozen-lockfile
The cache: line caches your package manager's global download cache (something like ~/.npm) and keys it off your lockfile's hash. When the lockfile hasn't changed, the cache restores and the install is near-instant.
The important thing to know here is this caches the package manager's global cache, not node_modules. Caching node_modules directly is a classic mistake. It breaks across Node versions, it fights the clean-install behavior of npm ci, and it causes more problems than it solves. Cache the global store and let the install step do its job.
.NET:
1env:2 NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages3steps:4 - uses: actions/checkout@v65 - uses: actions/setup-dotnet@v56 with:7 dotnet-version: 8.x8 cache: true9 cache-dependency-path: '**/packages.lock.json'10 - run: dotnet restore --locked-mode
The .NET setup has a sharp edge that fails silently: cache: true requires NuGet lock files. Without them, the cache key has nothing meaningful to hash, so it never updates and never helps, and nothing warns you. To enable lock files, add <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> to your project files, commit the resulting packages.lock.json, and restore with --locked-mode. Then the cache works as intended.
The same cache: parameter exists across the ecosystem: Python ('pip' or 'poetry'), Java ('maven' or 'gradle'), Go (cache: true), Ruby (bundler-cache: true). Whatever stack you're on, check whether your setup-* action already does this before reaching for anything fancier.
The general case: actions/cache
When you need to cache something the setup actions don't cover, actions/cache handles it. It comes down to three ideas:
1- uses: actions/cache@v52 with:3 path: ~/.npm4 key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}5 restore-keys: |6 ${{ runner.os }}-npm-
The key is an exact-match identifier: if a cache with that key exists, you get a hit. The restore-keys are ordered fallback prefixes: if the exact key misses (say your lockfile changed), GitHub restores the most recent cache whose key starts with one of the prefixes, so you start from a recent cache and only download the difference. And path is simply what gets cached.
Expo and React Native
Mobile builds deserve their own note because they're where caching saves the most. Native builds are slow and the runners are expensive.
If you're building natively inside Actions, cache the Node store with setup-node as above, then add actions/cache entries for Gradle (~/.gradle/caches and ~/.gradle/wrapper) and CocoaPods (Pods/ and ~/Library/Caches/CocoaPods). The native dependency caches are where the real time goes on mobile.
Docker layer caching
If you build container images, caching layers between runs is a big win, and the easiest backend needs no extra infrastructure because it uses the Actions cache itself:
1- uses: docker/setup-buildx-action@v42- uses: docker/build-push-action@v73 with:4 context: .5 push: true6 tags: user/app:latest7 cache-from: type=gha8 cache-to: type=gha,mode=max
mode=max is the key here - it caches all intermediate build layers rather than just the final ones, which means a much higher hit rate on later builds. If your images are big enough to blow past the cache budget (more on that limit below), switch to a registry-based cache backend instead.
Pitfalls
A few traps catch almost everyone at least once.
The cache that never invalidates: a static key, or a hashFiles() pattern that matches no real files, produces one cache created on day one and reused forever, even after dependencies change. Always hash your actual lockfile.
The 10 GB ceiling: each repository gets a 10 GB cache budget. Entries unused for 7 days get evicted, and when you're over budget the least recently used go first. Keys that are too granular (keyed on the commit SHA, for instance) create a brand-new cache on every commit, blow through the budget, and thrash out the useful caches before they're ever reused. Key on the lockfile and lean on restore-keys.
Cross-OS confusion: caches are tied to the OS and architecture that created them. Include ${{ runner.os }} in your key so you don't try to restore a Linux cache onto a Windows runner.
Win 2: don't run what you don't need
Caching makes the work cheaper. This category makes the work disappear, which is cheaper still. There are two levers: don't run jobs for changes they don't care about, and cancel runs that are already obsolete.
Filter by what changed
The simplest filter is built into the workflow trigger and costs nothing:
1on:2 push:3 paths: ['src/**', '.github/workflows/ci.yml']4 paths-ignore: ['**.md', 'docs/**']
Now a pull request that only touches documentation won't fire your whole test suite. The limitation is that native paths filtering works at the workflow level; it decides whether the entire workflow runs, not individual jobs.
For monorepos, where the backend job should only run when backend files change, you need job-level filtering, and dorny/paths-filter is the usual tool. One lightweight job detects what changed and exposes the results as outputs, then each real job gates on those outputs:
1jobs:2 changes:3 runs-on: ubuntu-latest4 outputs:5 backend: ${{ steps.filter.outputs.backend }}6 frontend: ${{ steps.filter.outputs.frontend }}7 steps:8 - uses: dorny/paths-filter@v49 id: filter10 with:11 filters: |12 backend: ['backend/**']13 frontend: ['frontend/**']14 backend:15 needs: changes16 if: ${{ needs.changes.outputs.backend == 'true' }}17 runs-on: ubuntu-latest18 steps: [ ... ]
A gotcha with required status checks: a job skipped by an if: condition reports no status at all, which can leave a required check waiting forever. The usual fix is a final aggregator job with if: always() that inspects the results of the jobs it depends on and reports a single pass or fail.
Cancel obsolete runs
Possibly the cheapest win in the whole post. A developer pushes a commit, then pushes a fix thirty seconds later. The first run is now pointless, but by default GitHub runs both to completion and bills you for both. Concurrency groups fix that:
1concurrency:2 group: ${{ github.workflow }}-${{ github.ref }}3 cancel-in-progress: true
A new push to a branch now cancels any in-progress run for that branch. If agents are pushing rapid fix-up commits to a PR, this single block is doing more for your bill than anything else on the page. One nuance: use cancel-in-progress: true for CI and pull-request workflows, where only the latest commit matters, and false for deployment workflows, where you want every deploy to queue and finish rather than die mid-flight.
Skip draft pull requests
A lot of teams run their full pipeline on every push to every branch by default, which means paying for CI on half-finished code the author isn't asking feedback on yet. Tightening triggers to the main branch and meaningful pull-request events is a quick change.
1on:2 push:3 branches: [main]4 pull_request:5 types: [opened, reopened, synchronize, ready_for_review]
ready_for_review is the key event: CI kicks in when a draft PR is marked ready, not while it's still a draft.
Win 3: parallelize, and know what you're buying
This is the one category that breaks the faster-equals-cheaper rule. Parallelization buys you speed, not necessarily fewer minutes.
Matrix builds
A matrix runs the same job across several configurations at once, like multiple language versions or operating systems:
1strategy:2 fail-fast: true # cancels the rest on first failure — saves minutes3 max-parallel: 44 matrix:5 node: [18, 20, 22]
fail-fast: true is the default and a quiet money saver: the moment one matrix job fails, GitHub cancels the siblings instead of running them to completion. Keep it on for pull-request gating, where one failure means the PR isn't merging anyway. Turn it off only when you genuinely need the full compatibility picture, meaning which versions broke, not just that one did.
Test sharding
Sharding splits one slow test suite across parallel jobs, each running a fraction of the tests. This one is more of a time saver than a cost saver but it helps when the goal is to get through the tests as quickly as possible.
1strategy:2 matrix:3 shard: [1, 2, 3, 4]4steps:5 - run: npx playwright test --shard=${{ matrix.shard }}/4
The reason I said this is a time saver and not a cost saver is you have the potential to spend more with test sharding. Say your test suite takes 18 minutes. Split it into four shards and each runs in about 5, but you're running four jobs, so your total billable minutes are roughly 20, not 18. You paid two extra minutes. What you bought was results in 5 minutes instead of 18.
That's usually a trade worth making. Sharding saves developer time, and developer time costs vastly more than CI minutes. Just don't sell sharding to your team as a way to lower the bill, because it usually won't.
Most modern test runners shard natively (Jest, Playwright, pytest via pytest-split, .NET's test filtering), so you probably don't need anything custom.
Shorten the critical path
Use needs: to express dependencies between jobs, and arrange them so independent work runs side by side. Linting and unit tests don't depend on each other, so run them at the same time and gate the deploy behind both. What determines how long you wait for an answer is the critical path, the longest chain of dependent jobs, not the total amount of work. And if you're copy-pasting the same checkout, language setup, and cache-restore steps across a dozen workflows, pull them into a composite action or reusable workflow so there's one place to maintain them.
Win 4: right-size your runners
What hardware should your jobs run on, and should you ever run that hardware yourself?
Sometimes a bigger runner is cheaper
This sounds backwards until you do the math. GitHub's larger runners cost proportionally more per minute. Suppose a build takes 12 minutes on a standard 2-core Linux runner:
- 2-core, 12 minutes: $0.006 × 12 = $0.072
- 4-core, 5 minutes: $0.012 × 5 = $0.06
The bigger runner costs more per minute, finishes in fewer minutes, and in this case comes out cheaper overall while getting you results 7 minutes sooner. You win on both axes.
The catch: this only works if the job actually speeds up with more cores. Genuinely parallel work, like compiling many files or running many tests, scales well. A job bottlenecked on a single-threaded step or on network I/O won't get faster no matter how many cores you throw at it, and you'll just pay the higher rate for the same duration. Prove the slow part is CPU-bound and parallelizable before paying for a bigger runner, ideally by timing the job on both sizes.
The self-hosted question
At some point someone on your team asks: hosted minutes cost money and self-hosted runners are free, so why don't we run our own? Fair question, careful answer.
It's true that self-hosted runners don't consume your GitHub minutes. GitHub announced a small per-minute platform fee for self-hosted runners slated for early 2026, then postponed it indefinitely after community pushback; current documentation still lists self-hosted usage as free.
But a free runner is not free CI, and the gap is where teams get burned. You're paying for the underlying machines (VMs, a Kubernetes cluster, storage, network egress) whether they're running jobs or idling at 3 a.m.; hosted runners cost nothing when idle, and yours don't. Somebody has to patch the OS, update the runner software, monitor for failures, and debug the inevitable weird issues, and that ongoing engineering time is expensive. There's also a hard security rule that shapes the whole setup: never run untrusted pull requests from forks on self-hosted runners attached to a public repository, because a malicious PR can execute arbitrary code on your infrastructure. And there's no autoscaling out of the box; a handful of static runners becomes a bottleneck under load and a waste of money when idle, and doing it properly means operating something like Actions Runner Controller on Kubernetes, which is powerful and also a system you now own.
To decide, compare your monthly overage cost (the minutes beyond your plan's allowance) against the fully loaded cost of running your own: instance hours times the hourly rate, plus engineering hours times what that engineer costs. The commonly cited break-even lands somewhere past 5,000-10,000 Linux minutes per month, but it depends heavily on your utilization and how you value the maintenance burden.
When GitHub rolled out the 2026 pricing, it noted that roughly 96% of customers would see no change to their bill. Most teams are nowhere near the volume where self-hosting pays off. Exhaust caching, filtering, and cancellation first. Self-hosting answers a problem most teams don't have yet.
The macOS and iOS exception
Building iOS apps is the exception to all of the above. At 10× the Linux rate, a 15-20 minute iOS build costs roughly $0.93 to $1.24 per run. A team doing dozens of builds a day can spend hundreds to low thousands of dollars a month on macOS compute alone. That's where buying a Mac mini and running it yourself, or using a dedicated service like Xcode Cloud, starts to pay off far sooner than it would for Linux.
Before going that far, the cheaper move is structural: do as much as possible on Linux and reserve macOS strictly for the steps that truly need Xcode and the native toolchain. Linting, JavaScript tests, and type-checking all run fine on a Linux runner at 1× cost; spin up the expensive macOS runner only for the actual native compile. For Expo, combine this with the --no-wait trigger from earlier so you're never paying macOS rates to watch a progress bar.
The grab bag
These apply to every stack, and each takes about a minute to add.
Set timeout-minutes on every job. The default job timeout is six hours. A job that hangs, like a flaky test waiting on a connection that never comes, will burn 360 Linux minutes (or the equivalent of 3,600 on macOS) before GitHub kills it. Set a ceiling around twice the job's normal duration:
1jobs:2 test:3 timeout-minutes: 15
Use shallow and sparse checkouts. actions/checkout@v6 already defaults to a shallow clone (fetch-depth: 1), so you're probably fine there. For large monorepos, add a sparse checkout to pull only the directories a job actually needs.
Order your steps to fail fast. Run the cheap checks (lint, formatting, type-checking) before the expensive build and test steps. A formatting error should fail the run in 20 seconds, not after a 10-minute build.
Tune artifact retention. Uploaded artifacts default to 90 days of retention, and storage beyond your plan's allowance is billed. For build outputs and test reports you don't need long term:
1- uses: actions/upload-artifact@v42 with:3 name: build4 path: dist/5 retention-days: 7
Pin your action versions. At minimum, pin to a major version tag (@v6). At best, pin to a SHA to avoid supply chain attacks. The downside of the SHA is you have to remember to review the new versions and manually upgrade more frequently than pinning to a major version tag. Your team will have to choose what makes the most sense for them.
Audit your scheduled workflows. Do smoke tests against the staging site really need to run every night or can they run only when new changes are deployed?
Where to start
Here's the order I'd actually do it in. Each stage is harder and pays less than the one before, so don't skip ahead.
Stage 1 is free, low risk, and has the biggest payoffs:
- Add dependency caching everywhere via your setup-* action's cache: parameter.
- Add a concurrency block with cancel-in-progress: true to your CI and PR workflows.
- Add timeout-minutes to every job.
- Add paths filters and stop running CI on draft PRs.
- Audit for any stray Windows or macOS jobs that could run on Linux instead.
Stage 2, once stage 1 is in place:
- Add job-level path filtering for monorepos with dorny/paths-filter or another equivalent action.
- Shard your slowest test suites for faster feedback.
- Add Docker layer caching if you build images.
Stage 3, only if your bill is still high after all that:
- Test whether a larger runner is cheaper for your proven CPU-bound jobs.
- Model self-hosted runners against the break-even, and only commit if the numbers, including maintenance time, actually clear it.
Wrapping up
Everything here traces back to that one billing detail at the top: GitHub charges by the minute, so a faster job is a cheaper job, automatically, on every run from now on. And with agents multiplying how often those runs fire, the return on each shaved minute keeps growing.
Most of the savings come cheap. You don't need to self-host anything or re-architect your pipelines. For the large majority of teams, the stage 1 list (caching, cancelling obsolete runs, timeouts, path filters, and getting off macOS where you don't need it) does the heavy lifting.
After that you can continue iterating and tweaking the pipelines to squeeze out every speed improvement you can get or just switch to a self-hosted runner if the math makes sense.