⚡ GitHub Actions: Stop Burning CI Minutes Like It's Free Money
⚡ GitHub Actions: Stop Burning CI Minutes Like It's Free Money
Let me paint you a picture. It's 4:58 PM on a Friday. You push a hotfix. You watch the CI pipeline spin up. Eight minutes pass. Then twelve. Then fifteen. You miss your deploy window. You miss your dinner reservation. You miss the point entirely.
Sound familiar? Good. Let's fix it.
GitHub Actions is a fantastic CI/CD platform — but left unconfigured, it will happily churn through your free tier minutes like a kid at an all-you-can-eat buffet. With a few targeted changes, you can cut your pipeline times in half (or better) without changing a single line of application code.
The Problem With "Just Works" Workflows
When people first set up GitHub Actions, they typically copy a starter workflow, add their build commands, and call it a day. Here's what that usually looks like:
name: Build and Test
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install
- run: npm test
- run: npm run build
This works. It will pass code review. And it will also re-download every single npm package on every single push to every single branch, for the rest of time, forever. At roughly 30-60 seconds per install, you're throwing away minutes multiple times a day.
Fix #1: Cache Your Dependencies (Seriously, Please)
The single highest-ROI change you can make is adding dependency caching. GitHub Actions has a cache action built in, and setup-node can even handle it automatically:
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # 👈 This one line. Just this.
- run: npm ci # Use ci, not install — it's faster and deterministic
- run: npm test
- run: npm run build
The cache: 'npm' option automatically caches your node_modules based on your package-lock.json hash. When the lockfile doesn't change, the cache hits, and your install step drops from 45 seconds to about 3. For Python it's pip, for Go it's the module cache — every ecosystem has an equivalent.
Real lesson learned: I once worked on a team that was burning through 3,000+ CI minutes per month on a small side project. Turns out we had 12 developers pushing branches multiple times a day, all reinstalling 400MB of dependencies every time. After adding caching, we dropped to under 800 minutes. The free tier covered us completely.
Fix #2: Only Run What Actually Needs to Run
Not every push needs the full test suite. Not every PR needs a production build. Use paths filters and job dependencies to run only what's necessary:
name: Smart CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
needs: lint # 👈 Don't bother testing if linting fails
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
deploy:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main' # 👈 Only deploy from main
steps:
- run: echo "Deploying to production..."
The needs keyword creates a dependency graph. If linting fails, tests don't even start — you get faster feedback and burn fewer minutes. The if condition on the deploy job means feature branches never accidentally trigger deployments.
Fix #3: The Matrix Strategy (Use It Wisely)
Matrix builds are powerful but easy to abuse. Testing across 4 Node versions × 3 operating systems = 12 parallel jobs. That sounds great until you realize you're running 12× the minutes simultaneously.
A smarter approach: run your full matrix only on main and PRs targeting main. For feature branch pushes, just run one combination:
jobs:
test:
strategy:
matrix:
node: ${{ github.ref == 'refs/heads/main' && fromJson('[18, 20, 22]') || fromJson('[20]') }}
os: ${{ github.ref == 'refs/heads/main' && fromJson('["ubuntu-latest", "windows-latest"]') || fromJson('["ubuntu-latest"]') }}
runs-on: ${{ matrix.os }}
Yes, that expression is ugly. Yes, it saves you 80% of your matrix minutes on day-to-day development. Worth it.
Bonus: Concurrency Groups
If you push twice in a row quickly (commit, realize you forgot a semicolon, commit again), GitHub will happily run both pipelines in parallel. Add concurrency groups to cancel the old one automatically:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Five lines. Done. No more waiting for yesterday's outdated pipeline to finish.
The Numbers Don't Lie
Here's what these changes typically look like in practice:
| Optimization | Time Saved Per Run | Minutes Saved Per Month* |
|---|---|---|
| Dependency caching | 30-60s | 500-1000 |
| Job dependency graph | 1-3 min | 800-2000 |
| Branch filtering | Varies | 200-500 |
| Concurrency cancellation | Varies | 300-600 |
*Estimates based on a 10-person team with moderate push frequency.
The Bigger Lesson
CI/CD pipelines are infrastructure. Like any infrastructure, they need maintenance, tuning, and occasional questioning of assumptions. "It works" is the floor, not the ceiling.
The best pipelines I've seen share one trait: someone cared enough to treat them as a first-class product, not an afterthought. They cache aggressively, fail fast, and only do work that matters for the current context.
Your developers spend hours per week waiting for CI. That time compounds. Invest 30 minutes optimizing your workflow today, and you'll pay back that investment within the week.
What's your worst CI/CD horror story? Drop it in the comments — I promise mine involves accidentally deploying to production from a branch called test-please-ignore. We've all been there. ✌️