⚡ 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:
paths-ignore: Pushing a README change? Don't run the full test suite. Nobody needs that.- Conditional deployment: The
deployjob only runs on pushes tomain, 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 ciinstead ofnpm install? (It's faster and deterministic) - Do your workflows have
paths-ignorefor 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.