Introduction
Building a complete CI/CD pipeline is one of the most impactful investments a development team can make. A well-designed pipeline automates the tedious, error-prone aspects of software delivery: running tests on every push, building artifacts, deploying to staging, and releasing to production. GitHub Actions has emerged as the platform of choice for this work because it lives where your code lives, requires no external service configuration, and scales from side projects to enterprise monorepos.
The difference between a basic workflow and a complete pipeline is substantial. A basic workflow might run tests on push. A complete pipeline includes linting, type checking, unit tests, integration tests, end-to-end tests, security scanning, performance benchmarking, artifact building, multi-environment deployment, and automated rollbacks. Each stage acts as a quality gate, and the pipeline as a whole provides confidence that every commit reaching production has been thoroughly vetted.
This guide walks through building a production-grade CI/CD pipeline with GitHub Actions from the ground up. We cover secret management for sensitive credentials, matrix builds for cross-platform testing, reusable actions to eliminate duplication, deployment strategies with environment protection, and monitoring the pipeline itself. Whether you are building a Node.js API, a React frontend, or a multi-service architecture, the patterns in this guide will help you create a pipeline that your team can trust.
Understanding Pipeline Design: Core Concepts
A complete CI/CD pipeline follows the principle of progressive quality gates. Each stage is more expensive to run than the previous one, so cheaper checks run first to fail fast. Linting catches typos and style issues in seconds. Unit tests verify logic in minutes. Integration tests validate component interactions. End-to-end tests verify the full system. Each stage that passes increases confidence that the code is shippable.
Pipeline Stages Architecture
The typical pipeline follows this progression: pre-checks (lint, type check) → unit tests → integration tests → build → security scan → deploy to staging → smoke tests → deploy to production. The key design principles are: fail fast with cheap checks, run independent stages in parallel, and gate expensive operations behind cheaper ones.
Secrets and Environment Variables
GitHub provides encrypted secrets at the repository, organization, and environment levels. Repository secrets are available to all workflow runs. Environment secrets are scoped to specific deployment environments and can require approval before access. Organization secrets can be shared across multiple repositories.
# Using secrets in workflows
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
# Environment-specific secrets
jobs:
deploy:
environment: production # Uses production environment secrets
steps:
- run: echo "Deploying with ${{ secrets.PROD_API_KEY }}"OIDC Authentication
OpenID Connect (OIDC) lets your workflows authenticate with cloud providers without storing long-lived credentials as secrets. GitHub generates a short-lived token that the cloud provider validates, eliminating the risk of leaked access keys.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1Architecture and Design Patterns
The Trunk-Based Development Pipeline
Trunk-based development with short-lived feature branches is the ideal workflow for CI/CD. Developers commit to main frequently (at least daily) through small pull requests. The pipeline validates each PR with a quality gate, and merges to main trigger automatic deployment to staging. Production deployments happen on a schedule or through manual approval.
The Deployment Pipeline with Environments
GitHub Environments provide the deployment progression from development to staging to production, with protection rules at each stage.
jobs:
deploy-dev:
environment: development
runs-on: ubuntu-latest
steps:
- run: echo "Auto-deploy to dev"
deploy-staging:
needs: deploy-dev
environment: staging
runs-on: ubuntu-latest
steps:
- run: echo "Auto-deploy to staging"
deploy-production:
needs: deploy-staging
environment: production # Has required reviewers
runs-on: ubuntu-latest
steps:
- run: echo "Deploy to production after approval"Step-by-Step Implementation
Let us build a complete, production-grade CI/CD pipeline for a Node.js application with TypeScript, covering every stage from linting to production deployment.
The Complete Pipeline Definition
name: Complete CI/CD Pipeline
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
id-token: write
env:
NODE_VERSION: '20'
REGISTRY: ghcr.io
jobs:
# ===== Stage 1: Pre-checks (fast, fail early) =====
lint-and-typecheck:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
# ===== Stage 2: Unit Tests (parallel matrix) =====
unit-test:
needs: lint-and-typecheck
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
node: [18, 20, 22]
include:
- os: ubuntu-latest
node: 20
coverage: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test -- --ci --maxWorkers=2
- name: Upload coverage
if: matrix.coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/lcov.info
# ===== Stage 3: Integration Tests =====
integration-test:
needs: lint-and-typecheck
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
# ===== Stage 4: Build =====
build:
needs: [unit-test, integration-test]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
# ===== Stage 5: Docker Image =====
docker:
needs: build
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- 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
# ===== Stage 6: Deploy to Staging =====
deploy-staging:
needs: docker
if: github.ref == 'refs/heads/main'
environment: staging
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: |
echo "Deploying to staging..."
# Your staging deployment commands here
kubectl set image deployment/app \
app=${{ env.REGISTRY }}/${{ github.repository }}:sha-${GITHUB_SHA::7} \
--namespace=staging
- name: Smoke test staging
run: |
sleep 30
curl -f https://staging.example.com/health || exit 1
# ===== Stage 7: Deploy to Production =====
deploy-production:
needs: deploy-staging
if: github.ref == 'refs/heads/main'
environment: production
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: |
echo "Deploying to production..."
kubectl set image deployment/app \
app=${{ env.REGISTRY }}/${{ github.repository }}:sha-${GITHUB_SHA::7} \
--namespace=production
- name: Smoke test production
run: |
sleep 30
curl -f https://api.example.com/health || exit 1
- name: Notify team
if: always()
uses: slackapi/slack-github-action@v1
with:
payload: |
{"text": "Production deployment ${{ job.status }} for ${{ github.sha }}"}Real-World Use Cases
Use Case 1: Monorepo with Turborepo
For monorepos, you need to detect which packages changed and only run CI for those packages.
name: Monorepo CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.changes.outputs.api }}
web: ${{ steps.changes.outputs.web }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
api:
- 'packages/api/**'
web:
- 'packages/web/**'
api-ci:
needs: detect-changes
if: needs.detect-changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx turbo run test --filter=api
web-ci:
needs: detect-changes
if: needs.detect-changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx turbo run build --filter=webUse Case 2: Automated Dependency Updates with Auto-Merge
Combining Dependabot with auto-merge workflows keeps dependencies updated without manual intervention for safe updates.
name: Auto-merge Dependabot PRs
on:
pull_request:
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- uses: dependabot/fetch-metadata@v2
id: metadata
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Auto-merge minor and patch updates
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Use Case 3: Preview Deployments for PRs
Each pull request gets its own preview deployment, making it easy for reviewers to test changes.
name: PR Preview
on:
pull_request:
types: [opened, synchronize, closed]
jobs:
deploy-preview:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
environment:
name: pr-preview-${{ github.event.pull_request.number }}
url: https://pr-${{ github.event.pull_request.number }}.preview.example.com
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: echo "Deploying preview for PR #${{ github.event.pull_request.number }}"Use Case 4: Release Automation with Semantic Versioning
Automating version bumps, changelog generation, and GitHub releases based on conventional commits.
Best Practices for Production
- Use
--ciflag for test runners: Jest, Vitest, and other test runners behave differently in CI mode—disabling watch mode, using deterministic output, and failing on console warnings. - Set
timeout-minuteson every job: Prevent runaway jobs from consuming your entire monthly minutes allocation. Most jobs complete in under 15 minutes. - Use
concurrencywithcancel-in-progress: When multiple commits are pushed rapidly, cancel previous workflow runs instead of letting them all complete. - Fail fast in matrix builds: Set
fail-fast: true(the default) when you want the matrix to stop on first failure. Usefalseonly when you need to see all results. - Use artifacts for cross-job data: Pass build outputs between jobs using
upload-artifactanddownload-artifactinstead of rebuilding. - Cache Docker layers: Use
cache-from: type=ghaandcache-to: type=gha,mode=maxfor Docker builds to leverage GitHub Actions cache. - Use environments with required reviewers: Gate production deployments behind environment protection rules that require manual approval from designated reviewers.
- Monitor workflow run costs: Track Actions usage in billing settings. Use larger runners only when needed, and consider self-hosted runners for cost-intensive workflows.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Leaking secrets in logs | Security breach | Use ::add-mask:: or avoid echoing secrets; use environment protection |
| Not pinning action versions | Supply chain attacks from compromised actions | Pin to SHA, use Dependabot for action updates |
| Running all tests in one job | Slow feedback, no parallelism | Split into parallel jobs using matrix or separate job definitions |
Forgetting actions: write permission | Cannot dispatch actions or create releases | Explicitly set required permissions |
| Cache misses due to wrong key | Slow CI from re-downloading dependencies | Use hash of lockfile as cache key with restore-keys fallback |
| Service container health checks failing | Flaky integration tests | Add proper health check commands with retries and intervals |
Performance Optimization
# Optimized pipeline configuration
jobs:
fast-checks:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for tools that need it
# Turborepo remote caching
- run: npm ci
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
# Only lint changed files
- run: npx eslint $(git diff --name-only origin/main...HEAD -- '*.ts' '*.tsx')
build-with-cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Docker build with BuildKit caching
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxComparison with Alternatives
| Feature | GitHub Actions | CircleCI | GitLab CI | Jenkins |
|---|---|---|---|---|
| Free minutes | 2,000/month | 6,000/month | 400/month | Unlimited |
| Marketplace | 20,000+ actions | 3,500+ orbs | Templates | 1,800+ plugins |
| Container jobs | Yes | Yes | Yes | Plugin |
| Matrix builds | Native YAML | Parameterized | Parallel | Axis plugin |
| OIDC auth | Yes | No | Yes | No |
| Self-hosted | Yes | Yes | Yes | Primary model |
| Reusable workflows | Yes | Yes | Yes | Shared libraries |
| Runner OS options | Linux, macOS, Windows | Linux, macOS, Windows, GPU | Linux, macOS, Windows | Any |
Advanced Patterns
Reusable Workflows with Composite Actions
Organize reusable pipeline components into composite actions for maximum reuse across repositories.
# .github/actions/deploy/action.yml
name: 'Deploy Application'
description: 'Build and deploy to specified environment'
inputs:
environment:
description: 'Target environment'
required: true
image-tag:
description: 'Docker image tag'
required: true
runs:
using: 'composite'
steps:
- run: |
echo "Deploying ${{ inputs.image-tag }} to ${{ inputs.environment }}"
kubectl set image deployment/app \
app=${{ inputs.image-tag }} \
--namespace=${{ inputs.environment }}
shell: bash
- run: |
sleep 30
curl -f "https://${{ inputs.environment }}.example.com/health"
shell: bashWorkflow Dispatch with Inputs
on:
workflow_dispatch:
inputs:
environment:
description: 'Deploy to'
type: choice
options: [staging, production]
required: true
dry-run:
description: 'Dry run'
type: boolean
default: falseTesting Strategies
# Test workflows locally with act
act -l # List available workflows
act push # Simulate push event
act pull_request # Simulate PR event
act -j test --secret-file .env # Run with secrets from .env
# Validate workflow YAML syntax
actionlint .github/workflows/*.ymlFuture Outlook
GitHub Actions continues to expand with ARM runners, GPU runners for ML workloads, larger hosted runners, improved caching, and better monorepo support. The Actions marketplace ecosystem grows daily, and features like required workflows (enforced at the organization level) and reusable workflow improvements make it increasingly viable for enterprise-scale CI/CD. The trend toward supply chain security is driving adoption of SHA-pinned actions and OIDC authentication, both of which GitHub Actions supports natively.
GitHub Actions Security Hardening
Secure your GitHub Actions workflows by pinning action versions to specific commit SHAs instead of mutable tags. Use CODEOWNERS to require review for workflow file changes. Enable branch protection rules that require status checks to pass before merging. Use GitHub's dependency review action to prevent supply chain attacks through compromised dependencies. Limit workflow permissions to the minimum required using the permissions key. Use GitHub Environments with required reviewers for production deployments. Audit workflow logs for secret exposure and enable secret scanning for your repository.
GitHub Actions Self-Hosted Runners
Deploy self-hosted runners for workflows that need access to private networks, custom hardware, or specialized software. Self-hosted runners give you full control over the execution environment but require you to manage updates, security patches, and scaling. Use runner groups to control which repositories and organizations can access specific runners. Implement auto-scaling using tools like Actions Runner Controller (ARC) for Kubernetes or custom scaling scripts for cloud VMs. Monitor runner health and set up automatic runner replacement for failed instances.
Conclusion
A complete CI/CD pipeline with GitHub Actions is more than a convenience—it is a safety net that catches bugs, enforces standards, and automates the path from code commit to production deployment. By structuring your pipeline with progressive quality gates, caching for speed, matrix builds for confidence, and environment protection for safety, you create a system that scales with your team and codebase.
The key takeaways are: design your pipeline with fail-fast principles, use secrets and environments properly for security, leverage caching at every layer (dependencies, Docker, build outputs), and implement environment protection for production deployments. Start with the essentials—lint, test, build—and add stages incrementally as your team's needs grow. A well-maintained CI/CD pipeline is the backbone of a high-performing engineering organization.