GitHub Actions: Advanced Patterns for Production CI/CD
Reusable workflows, matrix strategies, caching, OIDC auth, and other GitHub Actions patterns for teams running serious CI/CD pipelines.
GitHub Actions: Advanced Patterns for Production CI/CD
Most GitHub Actions tutorials show the basics: checkout, install, test, done. That works for side projects. Production CI/CD needs more: reusable workflows, smart caching, secure secrets management, conditional deployments, and enough observability to debug failures quickly.
Here are the patterns worth knowing.
Reusable Workflows
When you have 10 repos with nearly identical CI configs, reusable workflows keep things DRY.
Define a reusable workflow (.github/workflows/ci-template.yml in a shared repo):
on:
workflow_call:
inputs:
node-version:
type: string
default: "20"
secrets:
NPM_TOKEN:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
- run: npm test
Call it from another repo:
jobs:
ci:
uses: myorg/shared-workflows/.github/workflows/ci-template.yml@main
with:
node-version: "22"
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Matrix Strategies
Test across multiple versions/platforms in parallel:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["18", "20", "22"]
fail-fast: false # Don't cancel other matrix jobs on first failure
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
Dynamic matrices — generate the matrix from a script:
jobs:
generate-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- id: set
run: |
echo "matrix=$(python scripts/get_changed_services.py)" >> $GITHUB_OUTPUT
build:
needs: generate-matrix
strategy:
matrix:
service: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
Dependency Caching
The actions/cache action (or the built-in cache option in setup actions) dramatically speeds up workflows.
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm # Caches ~/.npm based on package-lock.json hash
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip # Caches pip based on requirements files
For Docker layer caching:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=max
This caches Docker layers in GitHub’s Actions cache, making rebuilds much faster when only app code changes.
OIDC: Secretless Cloud Authentication
Storing long-lived cloud credentials as GitHub secrets is a security antipattern. Use OpenID Connect (OIDC) instead — GitHub Actions gets short-lived tokens directly from your cloud provider.
AWS example:
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: eu-west-1
- run: aws s3 sync ./dist s3://my-bucket/
No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed. The IAM role trusts the GitHub OIDC provider, and credentials are scoped to the specific repo and branch.
Environments and Deployment Protection
Use GitHub Environments to add approval gates and scope secrets to specific stages:
jobs:
deploy-prod:
environment:
name: production
url: https://myapp.com
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.PROD_API_KEY }} # Only available in production environment
In the GitHub UI, configure the production environment with:
- Required reviewers — manual approval before the job runs
- Wait timer — enforce a delay after staging deployments
- Branch restrictions — only allow deployments from
main
Concurrency Control
Prevent multiple deployments running simultaneously:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true # Cancel older runs when a new one starts
For deployments where you want to queue rather than cancel:
concurrency:
group: deploy-production
cancel-in-progress: false # Wait for the running deployment to finish
Workflow Triggers: Beyond push and pull_request
on:
schedule:
- cron: "0 6 * * 1" # Every Monday at 6 AM UTC
workflow_dispatch: # Manual trigger with inputs
inputs:
environment:
type: choice
options: [staging, production]
required: true
pull_request:
types: [opened, synchronize, reopened]
paths:
- "src/**" # Only trigger on src changes
- "package.json"
release:
types: [published] # Trigger on GitHub release creation
Job Dependencies and Outputs
jobs:
build:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.build.outputs.tag }}
steps:
- id: build
run: |
TAG=$(git rev-parse --short HEAD)
echo "tag=$TAG" >> $GITHUB_OUTPUT
docker build -t myapp:$TAG .
deploy-staging:
needs: build
runs-on: ubuntu-latest
steps:
- run: kubectl set image deployment/myapp myapp=myapp:${{ needs.build.outputs.image-tag }}
deploy-prod:
needs: [build, deploy-staging]
environment: production
runs-on: ubuntu-latest
steps:
- run: kubectl set image deployment/myapp myapp=myapp:${{ needs.build.outputs.image-tag }}
Composite Actions
Package reusable steps into a composite action (can live in your repo or a dedicated action repo):
# .github/actions/setup-app/action.yml
name: Setup Application
inputs:
node-version:
default: "20"
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
shell: bash
- run: npm run build
shell: bash
Use it:
- uses: ./.github/actions/setup-app
with:
node-version: "22"
Debugging Tips
Enable debug logging by setting repository secrets:
ACTIONS_RUNNER_DEBUG=trueACTIONS_STEP_DEBUG=true
Or add a tmate session for interactive debugging:
- uses: mxschmitt/action-tmate@v3
if: failure() # Only on failure
GitHub Actions is powerful enough to handle the most complex CI/CD scenarios. The patterns above — reusable workflows, OIDC auth, environment gates — are the difference between a hobby pipeline and a production-grade one.