Design production-grade CI/CD pipelines using GitHub Actions with matrix builds, environment protection rules, and automated rollback strategies.
Beyond Basic Workflows
Most teams start with a simple GitHub Actions workflow that runs tests on push. But production-grade CI/CD requires much more: matrix builds across runtime versions, environment protection rules, secrets management, caching strategies, artifact handling, and automated rollback triggers.
This guide covers advanced patterns that we implement when setting up DevOps pipelines for engineering teams shipping to production multiple times per day.
Pipeline Architecture
A robust CI/CD pipeline has distinct stages with clear gates between them:
Build Stage: Compile, lint, and run unit tests. Fail fast on code quality issues.
Test Stage: Integration tests, E2E tests, and security scans run in parallel. Each test suite runs in its own job for isolation and parallelism.
Deploy Stage: Progressive deployment through staging, canary, and production environments with manual approval gates.
Advanced Workflow Configuration
name: Production Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run build
- uses: actions/upload-artifact@v4
if: matrix.node-version == 20
with:
name: build-output
path: dist/
retention-days: 5
test:
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
test-suite: [unit, integration, e2e]
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_db
POSTGRES_PASSWORD: test_pass
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run test:${{ matrix.test-suite }}
env:
DATABASE_URL: postgres://postgres:test_pass@localhost:5432/test_db
Key Design Decisions
The concurrency block cancels in-progress runs when new commits arrive on the same branch - essential for busy repositories where developers push frequently.
Setting fail-fast: false on the test matrix ensures all test suites complete even if one fails. This gives developers complete failure information in a single run rather than requiring multiple push-and-wait cycles.
Environment Protection Rules
GitHub Environments provide approval gates and deployment tracking:
deploy-staging:
needs: [test]
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy to staging
run: |
aws s3 sync dist/ s3://staging-bucket/ --delete
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DIST_ID }} \
--paths "/*"
deploy-production:
needs: [deploy-staging]
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy to production
run: |
aws s3 sync dist/ s3://production-bucket/ --delete
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.PROD_CF_DIST_ID }} \
--paths "/*"
Configure the production environment in GitHub to require manual approval from team leads. The workflow pauses at the deploy-production job until an authorized reviewer approves.
Caching for Speed
Aggressive caching cuts build times by 60-80 percent:
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
.next/cache
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
deps-${{ runner.os }}-
Automated Rollback
Implement post-deployment health checks that trigger automatic rollback:
- name: Smoke test
id: smoke
continue-on-error: true
run: |
for i in {1..5}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://example.com/healthz)
if [ "$STATUS" != "200" ]; then
echo "Health check failed with status $STATUS"
exit 1
fi
sleep 5
done
- name: Rollback on failure
if: steps.smoke.outcome == 'failure'
run: |
aws s3 sync s3://production-backup/ s3://production-bucket/ --delete
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.PROD_CF_DIST_ID }} \
--paths "/*"
echo "::error::Deployment rolled back due to failed health checks"
exit 1
Security Scanning
Add SAST and dependency scanning as required checks:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
severity: 'CRITICAL,HIGH'
exit-code: '1'
Best Practices
- Keep workflows DRY using reusable workflows and composite actions
- Pin action versions to specific SHA hashes, not tags, for supply chain security
- Use OIDC for AWS authentication instead of long-lived access keys
- Store deployment metadata (commit SHA, timestamp, actor) for audit trails
- Set up monitoring and alerting to catch issues that automated tests miss
Implementing these patterns transforms CI/CD from a simple automation layer into a reliable software delivery system.
Or read how we handle it in DevOps as a Service.
Related Articles
From Code to Production: A Guide to Automating Laravel Deployments with GitHub Actions
Learn how to build a fully automated CI/CD pipeline for Laravel using GitHub Actions, covering SSH key setup, workflow configuration, testing, and deployment to production servers.
Server & DevOpsHow to Run Cron in Kubernetes So Jobs Never Overlap or Vanish
Scheduled work in Kubernetes fails in two quiet ways, a slow job that starts a second copy of itself and a schedule that silently stops firing. Both are configuration, not luck. This covers the CronJob fields that control concurrency, missed schedules, history retention and failure handling, with the actual defaults, so the nightly billing run is still there in the morning and there is a log to read when it is not.
Server & DevOpsHow to Set Requests and Limits From Real Usage Instead of Guesses
Copied resource blocks cause two expensive problems at once, pods killed for memory they never used and latency nobody can explain. This shows how to read what your workloads actually consume, how CPU throttling shows up in metrics, and why a CPU limit and a memory limit are completely different decisions. The result is a set of numbers you can defend in a review instead of numbers that were inherited from a tutorial.