🚀 GitHub Actions Tips That Will Actually Change Your Life
🚀 GitHub Actions Tips That Will Actually Change Your Life
Let me paint you a picture: it's 4 PM on a Friday. You push a "small" hotfix. Your pipeline starts. The little yellow dot spins. And spins. And spins. Twenty minutes later, it fails on step 37 of 40 because of a flaky test that has nothing to do with your change. You've missed the deployment window. The weekend is ruined.
We've all been there. GitHub Actions is incredibly powerful, but most teams use about 15% of its potential and spend the other 85% of their time debugging YAML indentation. Let's fix that.
1. Cache Everything (No, Seriously, Everything)
The number one reason pipelines are slow is that they reinstall dependencies from scratch on every single run. Your node_modules folder hasn't changed in two weeks, but you're re-downloading the internet on every push.
The fix is embarrassingly simple:
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
The hashFiles trick is the magic here. The cache key changes only when your lockfile changes. Same lockfile? Cache hit. Your pipeline goes from 8 minutes to 90 seconds. Your team starts calling you a wizard.
Real-world lesson: On a monorepo project, we went from 22-minute pipelines to under 5 minutes just by properly caching npm, pip, and Docker layer caches. That's 17 minutes saved per run, across 50+ runs a day. Do the math — that's a serious amount of engineering time reclaimed.
2. Use Concurrency Groups to Kill Redundant Runs
Here's a scenario: you push a commit, the pipeline starts. You realize you forgot to add a semicolon. You push again. Now you have two pipelines running for the same branch, and the first one is going to deploy stale code even if it finishes first.
Enter concurrency groups:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Drop that into the top level of any workflow. Now when a new run starts for the same branch, the old one gets politely (and immediately) murdered. You save minutes, compute credits, and your sanity.
One caveat: don't use cancel-in-progress: true on your main branch deployments. You don't want a race condition where a mid-flight production deploy gets cancelled by a documentation fix. For main, set cancel-in-progress: false to let the current run finish before queuing the next one.
3. Matrix Builds Are Your Secret Weapon
Most teams test on one version of Node, one OS, call it a day. Then they get a bug report from someone running Node 18 on Windows and the whole world falls apart.
Matrix builds let you test multiple configurations in parallel without duplicating workflow files:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci && npm test
This single job definition runs 9 parallel jobs (3 OSes × 3 Node versions). If Node 22 on Windows breaks, you know before your users do. The fail-fast: false is important — it means a failure in one matrix cell doesn't cancel all the others, so you get the full picture of what's broken.
4. Secrets vs. Variables — Know the Difference
GitHub gives you two ways to store configuration: Secrets (encrypted, masked in logs) and Variables (plain text, visible in logs). Most teams shove everything into Secrets out of habit, then wonder why they can't debug their pipeline config.
The rule of thumb:
- Secret: anything sensitive — API keys, passwords, tokens, certificates
- Variable: anything non-sensitive — environment names, feature flags, configuration values
env:
APP_ENV: ${{ vars.APP_ENV }} # "production" - visible in logs, fine
DATABASE_URL: ${{ secrets.DB_URL }} # never printed in logs
This matters more than you think. When you use Variables for non-sensitive config, you can see their values in the workflow run logs, which makes debugging a whole lot easier.
5. Composite Actions: DRY Your YAML
If you're copying the same 10-step "setup, cache, install, lint" sequence across five workflows, you're going to have a bad time the day you need to update it. You'll update four of them, forget the fifth, and spend a day tracking down why one pipeline behaves differently.
Composite Actions let you extract reusable steps into their own action. Create .github/actions/setup-node/action.yml:
name: 'Setup Node Environment'
description: 'Checkout, cache, and install node dependencies'
runs:
using: "composite"
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- run: npm ci
shell: bash
Then in any workflow, just call it:
- uses: ./.github/actions/setup-node
One place to update, all pipelines benefit. Beautiful.
The Bottom Line
GitHub Actions rewards developers who take the time to understand it. Cache your dependencies. Kill redundant runs with concurrency groups. Use matrix builds to catch cross-environment bugs early. Know your Secrets from your Variables. Composite Actions for anything you repeat more than twice.
Your future self — the one who isn't debugging a broken pipeline at 11 PM — will thank you.
What's the most painful GitHub Actions lesson you've learned? Drop it in the comments. I promise you're not alone, and sharing it might save someone else three hours of their life.
Now go update your workflows. That 20-minute pipeline isn't going to fix itself.