CI/CD Cost Optimization#
CI/CD costs grow quietly. A team of ten pushing five times a day, running a 15-minute pipeline on 4-core runners, burns through 2,500 build minutes per week. On GitHub Actions at $0.008/minute for Linux runners, that is $20/week. Scale to fifty developers with integration tests, matrix builds, and nightly jobs, and you are looking at $500-$2,000/month before anyone notices.
The fix is not running fewer tests or skipping builds. It is eliminating waste: jobs that use more compute than they need, caches that are never restored, full builds triggered by README changes, and runners sitting idle between jobs.
Runner Sizing: Right-Size, Do Not Over-Provision#
The default runner on most CI platforms is a general-purpose 2-vCPU machine. Many teams upgrade to 4- or 8-vCPU runners “because builds are slow” without measuring whether CPU is the actual bottleneck.
Measure First#
Before changing runner size, instrument your pipeline:
- name: Capture resource usage
run: |
# Run your build/test with time tracking
/usr/bin/time -v make build 2>&1 | tee build-timing.txt
# Check if build was CPU-bound or IO-bound
grep "wall clock" build-timing.txt
grep "Maximum resident" build-timing.txt
grep "Percent of CPU" build-timing.txtIf “Percent of CPU this job got” is under 50%, the job is IO-bound or waiting on network. A bigger runner will not help. Look at caching and dependency mirroring instead.
If CPU utilization is consistently above 80% during compilation, a larger runner pays for itself through shorter wall-clock time:
| Runner | Cost/min | Build time | Cost/build |
|---|---|---|---|
| 2-vCPU | $0.008 | 14 min | $0.112 |
| 4-vCPU | $0.016 | 8 min | $0.128 |
| 8-vCPU | $0.032 | 5 min | $0.160 |
| 16-vCPU | $0.064 | 3.5 min | $0.224 |
In this example, the 4-vCPU runner costs 14% more per build but saves 6 minutes. Whether that is worth it depends on how many builds you run and how much developer waiting costs. At 100 builds/day, the 4-vCPU saves 600 minutes of developer wait time per day for an extra $1.60.
GitHub Actions Larger Runners#
GitHub offers 4, 8, 16, 32, and 64-vCPU Linux runners. Configure them in repository settings, then reference by label:
jobs:
build:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
- run: make build -j8Use larger runners selectively. Your linting job does not need 8 cores. Apply larger runners only to compilation-heavy and test-heavy jobs.
Caching: The Highest-ROI Optimization#
Caching dependency downloads and build artifacts between runs is consistently the single most impactful cost optimization. A Go project downloading 200MB of modules on every run wastes both time and bandwidth.
Calculating Cache ROI#
Measure the time your pipeline spends downloading and compiling dependencies without cache:
# Without cache: 3 minutes downloading, 2 minutes compiling deps
# With cache: 10 seconds restoring cache
# Savings per run: ~4.5 minutes
# At $0.008/min on 2-vCPU: $0.036 saved per run
# At 80 runs/day: $2.88/day, $86/monthCache Configuration Patterns#
# Go modules + build cache
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
restore-keys: go-${{ runner.os }}-
# Node.js with npm
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
# Python with pip
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}The restore-keys prefix fallback is important. If go.sum changed (new dependency added), the exact key will miss, but the prefix match restores the previous cache. You re-download only the new dependency, not everything.
Docker Layer Caching#
Container builds benefit enormously from layer caching. Without it, every docker build re-executes every layer:
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/myorg/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxThe type=gha cache backend stores Docker layers in GitHub Actions cache. This is the easiest option. For larger images, type=registry stores layers in a container registry.
Spot and Preemptible Instances for Builds#
Self-hosted runners on spot instances cut compute costs by 60-90%. CI workloads are ideal for spot because they are short-lived, stateless, and tolerant of interruption – a preempted build simply retries.
AWS Spot with Actions Runner Controller (ARC)#
ARC provisions self-hosted GitHub Actions runners as Kubernetes pods. Configure spot node pools for CI workloads:
# EKS managed node group for CI runners
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
managedNodeGroups:
- name: ci-spot
instanceTypes: [m6g.xlarge, m6g.2xlarge, m7g.xlarge]
spot: true
minSize: 0
maxSize: 20
labels:
workload: ci-runnerARC runner scale set targeting the spot node pool:
# Helm values for actions-runner-controller
githubConfigUrl: "https://github.com/myorg"
maxRunners: 20
minRunners: 0
template:
spec:
nodeSelector:
workload: ci-runner
tolerations:
- key: "spot"
operator: "Equal"
value: "true"
effect: "NoSchedule"With minRunners: 0, you pay nothing when no jobs are running. Runners scale up on demand and terminate after each job.
GCP Preemptible Instances#
Same pattern on GCP. Create a node pool with preemptible or spot VMs and direct CI runners to it. Preemptible VMs cost 60-91% less than on-demand but are reclaimed after 24 hours or when capacity is needed.
Parallelism vs Cost Tradeoffs#
Running four test shards in parallel finishes 4x faster but consumes the same total build minutes. On hosted runners billed per minute, parallelism does not save money – it buys time.
# Sequential: 1 runner x 20 min = 20 build minutes, 20 min wall clock
# Parallel (4 shards): 4 runners x 5 min = 20 build minutes, 5 min wall clock
# Cost is identical. Developer wait time drops by 75%.On self-hosted runners, parallelism costs more because you need more capacity. On hosted runners where cost-per-build-minute is fixed, parallelism is free in dollar terms and saves wall-clock time.
The exception: matrix builds that test unnecessary combinations. Testing on [ubuntu, macos, windows] x [node-16, node-18, node-20] generates 9 jobs. If your app only deploys on Linux and supports Node 18+, you are running 7 unnecessary jobs. Prune the matrix:
strategy:
matrix:
os: [ubuntu-latest]
node: ["18", "20"]
include:
# Only test macOS on latest Node for compatibility check
- os: macos-latest
node: "20"Three jobs instead of nine. Same coverage for your deployment target.
Build Minute Budgeting#
Set a monthly build minute budget and track actual usage against it. GitHub provides usage reports in organization billing settings. For self-hosted runners, track with Prometheus metrics:
# Prometheus query: total runner-seconds consumed per day
sum(increase(github_runner_job_duration_seconds_total[24h]))Budget allocation guidelines:
- PR pipelines: 50-60% of total budget. This is where most builds happen.
- Merge pipelines: 15-20%. Less frequent but more comprehensive.
- Nightly/scheduled: 10-15%. Full suites, performance tests.
- Releases: 5-10%. Infrequent but resource-intensive (multi-arch builds, signing).
When approaching budget limits, the first things to cut are redundant matrix combinations and full test suites on every PR push (run diffs instead). The last thing to cut is the merge-to-main pipeline – that is your safety net.
Path-Based Filtering#
The easiest cost savings: do not run pipelines for changes that cannot break anything:
on:
push:
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/ISSUE_TEMPLATE/**'
- 'LICENSE'A documentation-only PR should not trigger a 15-minute build pipeline. Path filtering eliminates these wasted runs entirely.
Self-Hosted Runner Economics#
The break-even point for self-hosted runners depends on your usage volume:
GitHub-hosted: $0.008/min Linux, $0.016/min Windows, $0.08/min macOS. No fixed costs. Scales to zero.
Self-hosted (cloud): A t3.xlarge spot instance at ~$0.05/hour runs roughly 60 minutes of CI per hour. Effective cost: ~$0.0008/min – 10x cheaper than hosted. But you pay for idle time, and you need someone to maintain the infrastructure.
Break-even calculation: If your monthly hosted runner bill exceeds $500, self-hosted runners on spot instances almost certainly save money. Below $200/month, the operational overhead of managing runners likely exceeds the savings.
Common Mistakes#
- Upgrading runner size without measuring CPU utilization. A 16-vCPU runner for a job that spends 80% of its time downloading dependencies is wasting money. Fix the bottleneck, not the symptom.
- Caching everything. A cache that takes longer to restore than to rebuild is negative ROI. Measure restore time vs cold-build time for every cached path.
- Running full matrix on every PR push. Run the full matrix on merge to main. Run the primary target only on PR pushes.
- Ignoring scheduled workflow costs. A nightly pipeline running 7 days a week on a 16-vCPU runner for 2 hours is 3,360 expensive minutes per month. Make sure it is delivering proportional value.