January 5, 2024 · 4 min read
Build GitHub Actions Pipelines That Don't Blow Up Costs
Practical strategies to optimize GitHub Actions workflows for cost efficiency without sacrificing build speed or reliability.
January 5, 2024 · 4 min read
Practical strategies to optimize GitHub Actions workflows for cost efficiency without sacrificing build speed or reliability.
GitHub Actions pricing seems simple until you get a $2,000 bill for a month of CI/CD. Here's how to build pipelines that stay fast without destroying your budget.
GitHub Actions charges per minute of compute time:
| Runner Type | Price/Minute | Monthly Free (Public) |
|---|---|---|
| Linux | $0.008 | 2,000 minutes |
| Windows | $0.016 | - |
| macOS | $0.08 | - |
The catch: build time adds up fast when you have multiple workflows, matrix builds, and frequent commits.
Running your entire test suite on every commit is expensive and slow.
# Bad: Runs everything on every push
on:
push:
branches: ['**']
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
- run: npm run e2e
- run: npm run build
Installing dependencies from scratch every run wastes minutes.
Running jobs that could parallelize as a sequence doubles or triples build time.
Only run workflows when relevant files change:
on:
push:
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/ci.yml'
paths-ignore:
- '**.md'
- 'docs/**'
Cache everything that doesn't change often:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci --prefer-offline
- name: Cache build
uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-
Skip expensive jobs when they're not needed:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'api/**'
- 'packages/backend/**'
frontend:
- 'web/**'
- 'packages/frontend/**'
test-backend:
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd api && npm test
test-frontend:
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd web && npm test
Split tests across multiple runners:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4
Prevent runaway builds from eating your budget:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- run: npm ci
timeout-minutes: 5
- run: npm test
timeout-minutes: 10
Cancel redundant runs when new commits arrive:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
For compute-intensive jobs, self-hosted runners can cut costs significantly:
jobs:
heavy-build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: ./build-all.sh
| Option | Monthly Cost |
|---|---|
| GitHub-hosted Linux | $80 |
| Self-hosted EC2 t3.medium | ~$30 |
| Self-hosted EC2 Spot | ~$10-15 |
Add a workflow to track usage:
name: Usage Report
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9 AM
jobs:
report:
runs-on: ubuntu-latest
steps:
- name: Get usage
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api /orgs/${{ github.repository_owner }}/settings/billing/actions
After applying these optimizations to a mid-size Node.js monorepo:
| Metric | Before | After |
|---|---|---|
| Average build time | 18 min | 6 min |
| Monthly minutes | 45,000 | 12,000 |
| Monthly cost | $360 | $96 |
The goal isn't to minimize CI spend at all costs—it's to get maximum value from every minute of compute time. Fast feedback loops are worth paying for; wasted cycles are not.