GitHub Actions Advanced: Matrix Builds, Caching, Secrets
On this page
GitHub Actions makes it easy to get a basic CI pipeline running, but the difference between a workflow that works and one that's fast, secure, and maintainable comes down to three advanced features: matrix builds, caching, and secrets management. This guide walks through each with practical, copy-paste-ready examples and the gotchas that trip people up in production.
Matrix Builds: Test Everything in Parallel
A matrix build lets you run the same job across multiple combinations of operating systems, language versions, and configuration values — all in parallel. Instead of writing five near-identical jobs, you declare the axes and let GitHub Actions expand them.
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
This single job expands into nine parallel runs (3 operating systems × 3 Node versions). A few details worth internalizing:
fail-fast: falsekeeps every combination running even after one fails. The default (true) cancels the whole matrix on the first failure, which is great for fast feedback but bad when you want a complete picture of what's broken.max-parallelcaps how many matrix jobs run at once. Useful when you're hitting concurrency limits or want to be polite to a rate-limited external service.
Including and Excluding Combinations
Real matrices are rarely a clean Cartesian product. Use include to add one-off combinations and exclude to prune ones that don't make sense.
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
exclude:
- os: windows-latest
node: 18
include:
- os: ubuntu-latest
node: 22
experimental: true
The include block can also add new keys to existing combinations. Pair that with continue-on-error: ${{ matrix.experimental }} to let bleeding-edge versions fail without breaking your build.
Dynamic Matrices
For advanced setups, you can generate the matrix at runtime from a previous job's output — handy when the set of packages or services to test changes over time.
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.gen.outputs.matrix }}
steps:
- id: gen
run: echo "matrix=$(./scripts/build-matrix.sh)" >> "$GITHUB_OUTPUT"
test:
needs: setup
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- run: echo "Testing ${{ matrix.package }}"
The script just needs to emit a compact JSON object like {"package":["api","web","worker"]}.
Caching: Stop Reinstalling the Same Dependencies
Every workflow run starts on a clean runner, so without caching you re-download and rebuild your entire dependency tree each time. The actions/cache action persists directories between runs keyed on a hash of your lockfile.
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
The mechanics that matter:
keyis the exact cache identifier. When the lockfile changes, the hash changes, and you get a fresh cache (a cache miss).restore-keysare fallback prefixes. On a miss, GitHub finds the most recent cache whose key starts with one of these prefixes, so you restore a slightly stale cache and only install the diff instead of everything.- Always include
runner.osin the key. Caches are not portable across operating systems, and a Linux cache restored on Windows will cause confusing failures.
Use the Built-in Setup Cache First
Before reaching for actions/cache directly, check whether your setup-* action already supports caching. Most do, and it's a one-line change:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
This handles the path, key, and restore-keys for you. Use the lower-level actions/cache only for things the setup action doesn't cover — build outputs, compiled binaries, Docker layers, or tool-specific directories like ~/.cache/pip or the Go build cache.
Caching Gotchas
- Caches are immutable. Once a key is written, it can't be overwritten. If you need to bust a cache, change the key (bumping a
v1→v2prefix is a common trick). - Branch scoping. A cache created on a feature branch is readable from that branch and its descendants, but the default branch's caches are available to all branches. PRs can read the base branch's cache but write to their own scope.
- Eviction. GitHub evicts caches not accessed in 7 days, and each repo has a 10 GB limit. When you exceed it, the least-recently-used caches are deleted. Don't cache things that are cheap to regenerate.
- Cache vs. artifacts. Caching is for speeding up future runs of dependencies. Artifacts are for passing build outputs between jobs in the same run or downloading results. Don't confuse them.
Secrets: Handle Credentials Safely
Secrets are encrypted environment values you inject into workflows without committing them to the repo. Define them at the repository, environment, or organization level, then reference them through the secrets context.
- name: Deploy
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
run: ./deploy.sh
GitHub automatically masks secret values in logs, replacing them with ***. But masking is a safety net, not a guarantee — it only catches exact string matches.
Secrets Best Practices
- Never echo or print secrets. Even with masking, a base64-encoded or partially transformed secret can leak. Avoid
set -xin scripts that touch credentials. - Pass secrets as
env, not as command-line arguments. Process arguments can show up in process listings and logs; environment variables are safer. - Use environments for deployment gates. Define a
productionenvironment with required reviewers and scoped secrets. A job that targets it can't run until an approver signs off.
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
- Prefer OIDC over long-lived secrets. For cloud deployments (AWS, GCP, Azure), use OpenID Connect to exchange a short-lived GitHub token for temporary cloud credentials. There's no static secret to rotate or leak.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: us-east-1
- Guard against fork PRs. The
pull_requestevent from forks does not have access to secrets, by design — this prevents a malicious PR from stealing credentials. If you need secrets in PR checks, understand the security tradeoffs ofpull_request_targetbefore using it, and never check out untrusted code with secrets in scope. - Scope and rotate. Grant the narrowest permissions possible (
permissions:block at the job level), and rotate any secret you suspect was exposed immediately.
Putting It All Together
These three features compound. A well-built workflow runs a caching-accelerated test matrix across versions, then gates a secrets-scoped deploy behind a protected environment:
jobs:
test:
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
environment: production
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
The needs: test dependency ensures deploy only runs after every matrix combination passes, and the if condition restricts deploys to the main branch.
FAQ
Why is my cache never hitting?
The most common cause is a key that changes every run — for example, including a timestamp or commit SHA in the key. Keys should be derived from lockfile hashes so they only change when dependencies change. Also verify you included runner.os and that you're not silently exceeding the 10 GB repo limit and getting evicted.
Can I share a cache across the matrix?
Yes, if the cached content is identical across combinations, the same key restores the same cache. But if different Node or OS versions produce different dependency trees, include those values in the key (${{ matrix.os }}-${{ matrix.node }}-...) to avoid cross-contamination.
How many matrix jobs can I run at once?
A single workflow run supports up to 256 jobs in a matrix. Actual parallelism is bounded by your plan's concurrency limits and runner availability, so very wide matrices may queue. Use max-parallel to throttle deliberately.
What's the difference between a secret and an environment variable?
Plain environment variables (set under env: or repository variables) are stored in plaintext and visible in logs. Secrets are encrypted at rest and masked in logs. Use secrets for anything sensitive; use variables for non-sensitive config like region names or feature flags.
Do secrets work in pull requests from forks?
No. Workflows triggered by pull_request from a fork run without access to secrets to prevent credential theft. Use OIDC, the pull_request_target event (carefully), or a separate post-merge workflow for steps that genuinely need secrets.
Should I use OIDC or stored cloud secrets? Prefer OIDC whenever your cloud provider supports it. Short-lived tokens eliminate the risk of a leaked long-lived key and remove the rotation burden entirely. Reserve static secrets for third-party services that don't offer federated identity.
How do I force a cache refresh without changing dependencies?
Bump a version prefix in your cache key (e.g., v1-npm-... → v2-npm-...). Since caches are immutable and keyed by string, the new key guarantees a fresh write on the next run.