GitHub Actions Templates
Production-ready GitHub Actions workflow patterns for testing, building, and deploying applications.
Purpose
Create efficient, secure GitHub Actions workflows for continuous integration and deployment across various tech stacks.
When to Use
- Automate testing and deployment
- Build Docker images and push to registries
- Deploy to Kubernetes clusters
- Run security scans
- Implement matrix builds for multiple environments
Common Workflow Patterns
Pattern 1: Test Workflow
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
Reference: See assets/test-workflow.yml
Pattern 2: Build and Push Docker Image
name: Build and Push
on:
push:
branches: [main]
tags: ["v*"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Reference: See assets/deploy-workflow.yml
Pattern 3: Deploy to Kubernetes
name: Deploy to Kubernetes
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Update kubeconfig
run: |
aws eks update-kubeconfig --name production-cluster --region us-west-2
- name: Deploy to Kubernetes
run: |
kubectl apply -f k8s/
kubectl rollout status deployment/my-app -n production
kubectl get services -n production
- name: Verify deployment
run: |
kubectl get pods -n production
kubectl describe deployment my-app -n production
Pattern 4: Matrix Build
name: Matrix Build
on: [push, pull_request]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
run: pytest
Reference: See assets/matrix-build.yml
Workflow Best Practices
- Use specific action versions (@v4, not @latest)
- Cache dependencies to speed up builds
- Use secrets for sensitive data
- Implement status checks on PRs
- Use matrix builds for multi-version testing
- Set appropriate permissions
- Use reusable workflows for common patterns
- Implement approval gates for production
- Add notification steps for failures
- Use self-hosted runners for sensitive workloads
Reusable Workflows
# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
required: true
type: string
secrets:
NPM_TOKEN:
required: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- run: npm test
Use reusable workflow:
jobs:
call-test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: "20.x"
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Security Scanning
name: Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: "fs"
scan-ref: "."
format: "sarif"
output: "trivy-results.sarif"
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: "trivy-results.sarif"
- name: Run Snyk Security Scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
Deployment with Approvals
name: Deploy to Production
on:
push:
tags: ["v*"]
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy application
run: |
echo "Deploying to production..."
# Deployment commands here
- name: Notify Slack
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK }}
payload: |
{
"text": "Deployment to production completed successfully!"
}
Common Pitfalls
This section documents real failure modes and anti-patterns that cause GitHub Actions workflows to fail silently or unexpectedly.
1. Workflow Syntax Errors
Problem: YAML indentation, quotes, and expression syntax are the most common sources of silent failures.
Common Errors:
Incorrect indentation
# ❌ WRONG - Missing indentation
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: npm test
# ✅ CORRECT
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: npm test
Error message: "unexpected mapping value" or "mapping values are not allowed here"
Fix: YAML is 2-space indentation strict. Use a YAML linter like yamllint in your workflow.
Expression syntax confusion
# ❌ WRONG - Using $ without github context
- run: echo ${{ matrix.node-version }}
# ❌ WRONG - Missing quotes on complex expressions
env:
MY_VAR: ${{ github.event.pull_request.title }}
# ✅ CORRECT
- run: echo "${{ matrix.node-version }}"
# ✅ CORRECT with quotes for strings
env:
MY_VAR: "${{ github.event.pull_request.title }}"
Error message: "Unable to process template language" or "unrecognized named value"
Fix: Always quote expressions that will be used in shell commands or as strings. GitHub Actions requires proper quoting for safe expansion.
Multiline strings without pipe syntax
# ❌ WRONG - Multiline without | or >
- run: echo "Line 1
Line 2"
# ✅ CORRECT using pipe (preserves newlines)
- run: |
npm install
npm run build
npm test
# ✅ CORRECT using > (folds newlines)
- name: Summary
run: >
This is a long
command that spans
multiple lines
Error message: "expected '<block end>', but found '\\n'"
Fix: Use | for literal blocks (preserves newlines) or > for folded blocks (folds newlines to spaces).
2. Secret Handling Mistakes
Problem: Secrets are the most frequently mishandled element. GitHub Actions has specific rules about when secrets are available.
Critical Mistakes:
Hardcoding secrets in workflow files
# ❌ ABSOLUTELY WRONG - Secret is now in git history
- run: |
aws s3 sync . s3://my-bucket \
--aws-access-key-id AKIA1234567890ABCDEF \
--aws-secret-access-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Error message: None initially, but this is a critical security vulnerability.
Fix: Always use ${{ secrets.SECRET_NAME }} and define secrets in GitHub repository settings.
Accessing secrets in non-secret contexts
# ❌ WRONG - Secrets not available in if: conditionals
if: ${{ secrets.DEPLOY_KEY != '' }}
# ✅ CORRECT - Secrets work in env: blocks at job and step level
jobs:
deploy:
runs-on: ubuntu-latest
env:
MESSAGE: ${{ secrets.MY_SECRET }} # ← Works at job level
steps:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # ← Works at step level
run: |
# Now DEPLOY_KEY is available here
./deploy.sh
Error message: Secret appears as *** (masked) but is actually empty/undefined when used in if: conditions.
Fix: Use secrets in env: blocks at job or step level. Never use secrets in if: conditions — they are not available there.
Forgetting to mark outputs as sensitive
# ❌ WRONG - Logs leak credentials
- name: Get credentials
run: |
TOKEN=$(curl -s https://api.example.com/token)
echo "TOKEN=$TOKEN" >> $GITHUB_OUTPUT
echo "Got token: $TOKEN" # ← Logged!
# ✅ CORRECT - Mark as secret in action output
- name: Get credentials
id: creds
run: |
TOKEN=$(curl -s https://api.example.com/token)
echo "token=$TOKEN" >> $GITHUB_OUTPUT # ← Set output
# Then reference as:
# ${{ steps.creds.outputs.token }}
Error message: Credentials appear in plain text in workflow logs.
Fix: Use $GITHUB_OUTPUT for sensitive values, and never echo them directly.
Wrong secret context in reusable workflows
# ❌ WRONG - Parent secrets aren't automatically passed
jobs:
call-workflow:
uses: ./.github/workflows/deploy.yml
# secrets: inherit not specified!
# ✅ CORRECT
jobs:
call-workflow:
uses: ./.github/workflows/deploy.yml
secrets: inherit # ← Pass all parent secrets
Error message: Workflow runs but secrets are undefined in called workflow.
Fix: Include secrets: inherit when calling reusable workflows that need secrets.
3. Permission Issues
Problem: GITHUB_TOKEN has limited scopes by default, and permissions are easy to get wrong.
Common Problems:
Insufficient token permissions
# ❌ WRONG - Trying to write packages with default permissions
- name: Push to Docker registry
run: |
docker push ghcr.io/${{ github.repository }}:latest
# Fails with "permission denied" because GITHUB_TOKEN lacks 'packages: write'
# ✅ CORRECT - Explicitly grant permissions
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # Read repo code
packages: write # Write Docker images
id-token: write # For OIDC token exchange
steps:
- uses: actions/checkout@v4
- name: Push to Docker registry
run: docker push ghcr.io/${{ github.repository }}:latest
Error message: "authentication failed, permission denied" or "Insufficient permissions" from API
Fix: Always explicitly declare permissions: at job level with required scopes (contents, packages, id-token, pull-requests, issues, deployments, etc.)
Trying to use GITHUB_TOKEN for operations it can't do
# ❌ WRONG - GITHUB_TOKEN can't trigger other workflows
- name: Trigger deployment
run: |
curl -X POST https://api.github.com/repos/${{ github.repository }}/dispatches \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-d '{"event_type":"deploy"}'
# Fails silently - GITHUB_TOKEN lacks workflow permissions
# ✅ CORRECT - Use a PAT (Personal Access Token) or GitHub App token
- name: Trigger deployment
run: |
curl -X POST https://api.github.com/repos/${{ github.repository }}/dispatches \
-H "Authorization: token ${{ secrets.WORKFLOW_PAT }}" \
-d '{"event_type":"deploy"}'
Error message: "Resource not accessible by integration" (HTTP 403)
Fix: GITHUB_TOKEN can't trigger workflows (repository_dispatch). Use a PAT stored in secrets for this.
Cross-repository access failures
# ❌ WRONG - GITHUB_TOKEN only has access to current repo
- name: Clone another repo
run: |
git clone https://github.com/other-org/private-repo.git
# Fails - GITHUB_TOKEN has no access
# ✅ CORRECT - Use a PAT with repo access
- name: Clone another repo
run: |
git clone https://x-access-token:${{ secrets.CROSS_REPO_PAT }}@github.com/other-org/private-repo.git
Error message: "fatal: could not read Username for 'https://github.com': terminal prompts disabled"
Fix: Use a Personal Access Token (PAT) with repo or read:repo_hook scope for cross-repository access.
4. Matrix Strategy Failures
Problem: Matrix builds are powerful but fail silently when combinations are wrong or when exclude/include are misused.
Common Pitfalls:
Undefined matrix context in steps
# ❌ WRONG - Using matrix variables that don't exist
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
# No 'version' defined here
steps:
- run: echo "Version is ${{ matrix.version }}" # ← Empty!
# ✅ CORRECT - Define all matrix variables used
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
version: ['1.0', '2.0']
steps:
- run: echo "Building for ${{ matrix.os }} v${{ matrix.version }}"
Error message: Matrix variable appears empty or undefined in logs.
Fix: All variables referenced in steps must be defined in strategy.matrix.
Broken exclude/include syntax
# ❌ WRONG - Invalid exclude syntax
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: ['18', '20']
exclude:
- os: windows-latest, node-version: '18' # ← Comma not allowed here
# ✅ CORRECT - Proper exclude syntax
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: ['18', '20']
exclude:
- os: windows-latest
node-version: '18'
# ✅ Using include for specific combinations
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
include:
- os: windows-latest
node-version: '20' # ← Only windows + node 20
Error message: Workflow fails to parse during validation phase.
Fix: exclude and include use YAML list syntax with separate lines for each property.
Cartesian product explosion
# ❌ WRONG - Creates 4 × 3 × 2 × 5 = 120 jobs unexpectedly
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest, linux-arm64]
node-version: ['16', '18', '20']
npm-version: ['8', '9']
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
# This is excessive and wastes CI minutes
# ✅ CORRECT - Use include for specific combinations
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
node-version: '20'
npm-version: '9'
- os: macos-latest
node-version: '20'
npm-version: '9'
- os: windows-latest
node-version: '18'
npm-version: '8'
Error message: None, but suddenly your CI spend quadruples.
Fix: Use include to define specific combinations instead of letting matrix create a Cartesian product.
5. Caching Mistakes
Problem: GitHub Actions caching is conservative—stale caches can persist for months and cause mysterious build failures.
Critical Issues:
Stale cache causing repeated failures
# ❌ WRONG - Cache key doesn't change, stale deps stay cached
- uses: actions/cache@v3
with:
path: node_modules
key: deps-${{ runner.os }} # ← Never changes!
restore-keys: |
deps-${{ runner.os }}
# ✅ CORRECT - Include lockfile hash in cache key
- uses: actions/cache@v3
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
deps-${{ runner.os }}-
Error message: Build passes locally but fails in CI with cryptic module errors.
Fix: Always include a hash of your dependency lock file in the cache key: ${{ hashFiles('**/package-lock.json') }} for npm, ${{ hashFiles('**/requirements.txt') }} for pip.
Incorrect cache path for language runtimes
# ❌ WRONG - Caching wrong path, cache misses happen
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20.x"
- uses: actions/cache@v3
with:
path: /tmp/node_modules # ← Wrong path!
key: deps-${{ hashFiles('package-lock.json') }}
# ✅ CORRECT - Let action handle caching, or use right path
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm" # ← Let the action handle caching
# OR if manual caching:
- uses: actions/cache@v3
with:
path: ~/.npm # ← Correct npm cache location
key: npm-${{ hashFiles('package-lock.json') }}
Error message: Workflow runs slowly, repeatedly downloading dependencies.
Fix: Use the built-in cache: parameter in setup-node@v4, or cache the language runtime's official cache directory (e.g., ~/.npm, ~/.pip-cache).
Cache invalidation race conditions
# ❌ WRONG - Multiple branches writing to same cache
branches:
- main
- develop
- '*'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v3
with:
path: dist
key: build-${{ hashFiles('package.json') }}
# develop branch builds with old deps, overwrites cache
# main branch then gets develop's stale build artifacts
# ✅ CORRECT - Include branch in cache key or read-only on non-main
- uses: actions/cache@v3
with:
path: dist
key: build-${{ github.ref }}-${{ hashFiles('package.json') }}
restore-keys: |
build-refs/heads/main-
build-refs/heads/develop-
Error message: Mysterious test failures after merging (cache was poisoned from other branch).
Fix: Include ${{ github.ref }} in cache key to isolate per-branch caches, or use read-only mode on non-main branches.
Docker layer cache not preserved
# ❌ WRONG - Rebuilds every layer despite cache-from
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myimage:latest
# cache-from: type=gha missing!
# ✅ CORRECT - Use GitHub Actions cache backend
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myimage:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Error message: Docker builds are extremely slow despite local caching working fine.
Fix: Always include cache-from: type=gha and cache-to: type=gha,mode=max in docker/build-push-action to cache layers across runs.
Debugging Workflows
Useful techniques when workflows fail:
- Enable debug logging: Set
ACTIONS_STEP_DEBUG=truesecret in repository settings to see detailed logs - Check permissions: Review job's
permissions:block when API calls fail - Validate YAML: Run
yamllintlocally on workflow files before pushing - Test matrix locally: Simulate matrix combinations in local test script
- Inspect cache: View cache entries in GitHub UI under "Actions" → "Caches"
- Review secrets: Confirm all
${{ secrets.NAME }}are defined in repository settings
Reference Files
assets/test-workflow.yml- Testing workflow templateassets/deploy-workflow.yml- Deployment workflow templateassets/matrix-build.yml- Matrix build templatereferences/common-workflows.md- Common workflow patterns
Related Skills
gitlab-ci-patterns- For GitLab CI workflowsdeployment-pipeline-design- For pipeline architecturesecrets-management- For secrets handling