Moving Beyond Basic GitHub Actions
Most GitHub Actions tutorials cover the same ground: a workflow that runs tests on push, maybe builds a Docker image. But production pipelines need much more: environment-based deployment gates, secure secret management, efficient caching, and maintainable workflow organization.
This guide covers the patterns that make GitHub Actions work at scale.
Environments and Deployment Gates
Environments add protection rules to your deployments:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
deploy-staging:
needs: test
runs-on: ubuntu-latest
environment: staging # Uses staging environment secrets
steps:
- name: Deploy to staging
run: ./deploy.sh staging
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} # From staging environment
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # Can require manual approval
# Production environment can be configured with:
# - Required reviewers (must approve before job runs)
# - Wait timer (delay before deployment)
# - Branch restrictions (only from main)
steps:
- name: Deploy to production
run: ./deploy.sh production
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} # From production environment
In GitHub Settings → Environments, configure:
- Required reviewers: Named people who must approve
- Wait timer: Delay (useful for canary monitoring periods)
- Deployment branches: Only specific branches can deploy here
OIDC Authentication (No Long-Lived Secrets)
Instead of storing cloud credentials as GitHub secrets, use OIDC to get temporary tokens:
name: Deploy with OIDC
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy-aws:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Get temporary AWS credentials via OIDC (no stored AWS secrets!)
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: us-east-1
# No access key or secret key needed!
- name: Deploy to ECS
run: aws ecs update-service --cluster prod --service my-app --force-new-deployment
deploy-gcp:
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123/locations/global/workloadIdentityPools/my-pool/providers/my-provider
service_account: deploy@my-project.iam.gserviceaccount.com
- run: gcloud run deploy my-service --image gcr.io/my-project/app:${{ github.sha }}
Matrix Strategies
Run jobs across multiple combinations:
jobs:
test:
strategy:
fail-fast: false # Don't cancel other matrix jobs on failure
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
exclude:
- os: macos-latest
node: 18 # Don't test Node 18 on macOS
include:
- os: ubuntu-latest
node: 20
experimental: true # Add extra variable for specific combo
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
build:
strategy:
matrix:
platform: [linux/amd64, linux/arm64] # Multi-arch Docker builds
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
platforms: ${{ matrix.platform }}
tags: my-app:${{ github.sha }}-${{ matrix.platform }}
Caching: Making Workflows Fast
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Node.js — cache node_modules
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Built-in caching for npm/yarn/pnpm
- run: npm ci # Uses cache on subsequent runs
# Explicit cache for more control
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: |
.next/cache
~/.npm
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
${{ runner.os }}-nextjs-
# Docker layer caching with GitHub Actions cache
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
cache-from: type=gha # Read from GitHub Actions cache
cache-to: type=gha,mode=max # Write to GitHub Actions cache
push: true
tags: my-app:${{ github.sha }}
Reusable Workflows
DRY principle for workflows — define once, call from multiple workflows:
# .github/workflows/reusable-deploy.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
image-tag:
required: true
type: string
secrets:
DEPLOY_TOKEN:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- name: Deploy ${{ inputs.image-tag }} to ${{ inputs.environment }}
run: |
curl -X POST https://deploy.example.com/deploy -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" -d '{"image": "${{ inputs.image-tag }}", "env": "${{ inputs.environment }}"}'
# .github/workflows/main.yml
name: Main Pipeline
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- id: meta
uses: docker/metadata-action@v5
with:
images: my-app
tags: type=sha
- uses: docker/build-push-action@v5
with:
tags: ${{ steps.meta.outputs.tags }}
push: true
deploy-staging:
needs: build
uses: ./.github/workflows/reusable-deploy.yml # Call reusable workflow
with:
environment: staging
image-tag: ${{ needs.build.outputs.image-tag }}
secrets: inherit # Pass all secrets through
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: production
image-tag: ${{ needs.build.outputs.image-tag }}
secrets: inherit
Composite Actions
For reusable step sequences within a workflow:
# .github/actions/setup-app/action.yml
name: Setup Application
description: Install dependencies and build the app
inputs:
node-version:
description: Node.js version
default: '20'
environment:
description: Build environment
required: true
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
env:
NODE_ENV: ${{ inputs.environment }}
- run: npm test -- --coverage
shell: bash
# Use the composite action
- uses: ./.github/actions/setup-app
with:
node-version: '20'
environment: production
Advanced Job Dependencies and Conditions
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- run: npm test
build:
needs: [lint, test] # Wait for both to pass
if: github.ref == 'refs/heads/main' # Only on main branch
runs-on: ubuntu-latest
steps:
- run: npm run build
notify-failure:
needs: [build]
if: failure() # Run if any previous job failed
runs-on: ubuntu-latest
steps:
- name: Notify team on Slack
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "🚨 Build failed: ${{ github.repository }}@${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
cleanup:
needs: [build]
if: always() # Run regardless of success/failure
runs-on: ubuntu-latest
steps:
- run: ./cleanup.sh
Workflow Security Best Practices
# Principle of least privilege for permissions
permissions:
contents: read # Default — restrict further in jobs
packages: write # Only if pushing to ghcr.io
id-token: write # Only if using OIDC
# Don't give pull-requests: write unless explicitly needed
# Pin action versions to exact SHAs (not @main or @v1)
# ❌ Vulnerable to supply chain attacks:
- uses: some-action/action@main
# ✅ Pinned to specific commit:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# Validate inputs for PR-triggered workflows (untrusted code)
- name: Validate PR title
run: |
TITLE="${{ github.event.pull_request.title }}"
# Sanitize — don't use PR content directly in shell commands
echo "PR title length: ${#TITLE}"
# Use GITHUB_TOKEN with minimal scope
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
A well-designed CI/CD pipeline is invisible in day-to-day development — it just works, runs fast, and deploys reliably. The investment in patterns like OIDC auth, reusable workflows, and proper caching pays dividends over the months and years of a project's lifetime.
→ Generate UUIDs for unique identifiers in your workflows with the UUID Generator.