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 a picture you might recognize. It's 4:58 PM on a Friday. You push a one-line CSS fix. GitHub Actions kicks off. You watch the little yellow dot spin... and spin... and spin. Seven minutes later, your lint step fails because of a missing semicolon. You fix it, push again. Another seven minutes. You're now eating dinner at your desk while your deploy pipeline installs node_modules for the third time today.

Sound familiar? Yeah. We've all been there.

Here's the thing — most GitHub Actions pipelines are spectacularly inefficient, not because developers are lazy, but because the defaults are designed to be safe, not fast. Let's fix that.

The #1 Crime: Not Caching Dependencies

If your workflow installs dependencies from scratch every single run, you're leaving massive performance gains on the table. Installing node_modules, Python packages, or Go modules doesn't change between commits unless your lockfile changes. So why are you doing it every time?

Here's a Node.js workflow with proper caching:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'  # This one line is doing a lot of heavy lifting

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

That cache: 'npm' option in setup-node automatically caches your node_modules based on your package-lock.json hash. No lockfile change? Cached install. We went from 3 minutes to 45 seconds on one of our projects just from this.

For more complex scenarios, reach for actions/cache directly:

- name: Cache Gradle packages
  uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
      ${{ runner.os }}-gradle-

The key is the magic here — it creates a unique cache key based on your dependency files. If nothing changed, you get a cache hit. If it did change, the restore-keys fallback grabs the closest previous cache. Smart.

Stop Running Everything on Every Push

Here's a workflow anti-pattern I see constantly: running your full test suite, linting, security scans, and deployment checks on every single push to every single branch, including that wip/trying-something-stupid branch you pushed at midnight.

Use path filters and branch rules to be smarter:

on:
  push:
    branches: [main, 'release/**']
    paths-ignore:
      - '**.md'
      - 'docs/**'
      - '.github/ISSUE_TEMPLATE/**'
  pull_request:
    branches: [main]
    paths:
      - 'src/**'
      - 'package*.json'
      - 'Dockerfile'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        run: echo "Deploying..."

Two key wins here:

  1. paths-ignore: Pushing a README change? Don't run the full test suite. Nobody needs that.
  2. Conditional deployment: The deploy job only runs on pushes to main, not on PRs. Your feature branches won't accidentally try to deploy to production at 2 AM.

The Lessons I Learned the Hard Way

Lesson 1: Concurrency groups save you from yourself.

Ever pushed a hotfix while a previous build was still running, and ended up with two deploys racing each other? Add this to your deploy jobs:

concurrency:
  group: production-deploy
  cancel-in-progress: true

The new push cancels the old one. No more race conditions. No more "which deploy actually won?" mysteries at 3 AM.

Lesson 2: Self-hosted runners are worth it at scale.

GitHub-hosted runners are great until you're paying $0.008/minute per run and you have a busy team. If your organization runs hundreds of workflows a day, a self-hosted runner on a beefy EC2 instance pays for itself quickly — and you get full control over the environment, faster network to your AWS resources, and no cold-start penalty.

Lesson 3: Secrets rotation matters more than you think.

Those ${{ secrets.DEPLOY_KEY }} references? Rotate them. Seriously. Most teams set secrets once and forget them. Build secret rotation into your quarterly checklist. One compromised personal access token that lived for two years once cost a team I know a very bad weekend.

Lesson 4: Check your runner OS version.

ubuntu-latest doesn't mean what it meant six months ago. GitHub periodically bumps the default. If your workflow has subtle environment dependencies, pin to ubuntu-22.04 or ubuntu-24.04 explicitly and upgrade on your schedule, not GitHub's.

The Quick Wins Checklist

Before I let you go, here's a fast checklist to audit your existing workflows:

  • Are you caching node_modules, pip packages, or other dependencies?
  • Are you using npm ci instead of npm install? (It's faster and deterministic)
  • Do your workflows have paths-ignore for docs and markdown?
  • Are deploy jobs gated on branch conditions?
  • Do you have concurrency groups for your deployment workflows?
  • Are you on actions/checkout@v4 (not v2 or v3)?
  • Do you run jobs in parallel where possible with needs?
  • Have you rotated your secrets recently?

If you checked all of those, you're doing great. If not — well, you've got some fun reading to do on Monday.

Go Make Your Pipeline Fast

Slow CI isn't just annoying, it actively hurts your team's velocity. Every minute your developers sit watching a spinner is a minute they're not reviewing PRs, writing code, or enjoying their Friday afternoon.

Start small: add caching to one workflow today. Measure the difference. Then tackle the next thing on the list. Small, steady improvements compound quickly.

Your future self — the one who pushes at 4:58 PM on a Friday and sees a green checkmark in under two minutes — will thank you.


Got a GitHub Actions tip that saved your team hours? Drop it in the comments or find me on Twitter. I'm always collecting these.

Thanks for reading!

Back to all posts