0x55aa
← Back to Blog

⚡ GitHub Actions: Stop Burning CI Minutes Like It's Free Money

5 min read

⚡ 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. ✌️