MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

GitHub Actions: Complete CI/CD Pipeline Guide

Build CI/CD with GitHub Actions: workflows, secrets, matrix builds, and reusable actions.

GitHub ActionsCI/CDDevOpsAutomation

By MinhVo

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.

Complete CI/CD pipeline architecture

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-1

Secret management and security in CI/CD

Architecture 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 }}"}

Automated testing and deployment visualization

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=web

Use 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

  1. Use --ci flag 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.
  2. Set timeout-minutes on every job: Prevent runaway jobs from consuming your entire monthly minutes allocation. Most jobs complete in under 15 minutes.
  3. Use concurrency with cancel-in-progress: When multiple commits are pushed rapidly, cancel previous workflow runs instead of letting them all complete.
  4. Fail fast in matrix builds: Set fail-fast: true (the default) when you want the matrix to stop on first failure. Use false only when you need to see all results.
  5. Use artifacts for cross-job data: Pass build outputs between jobs using upload-artifact and download-artifact instead of rebuilding.
  6. Cache Docker layers: Use cache-from: type=gha and cache-to: type=gha,mode=max for Docker builds to leverage GitHub Actions cache.
  7. Use environments with required reviewers: Gate production deployments behind environment protection rules that require manual approval from designated reviewers.
  8. 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

PitfallImpactSolution
Leaking secrets in logsSecurity breachUse ::add-mask:: or avoid echoing secrets; use environment protection
Not pinning action versionsSupply chain attacks from compromised actionsPin to SHA, use Dependabot for action updates
Running all tests in one jobSlow feedback, no parallelismSplit into parallel jobs using matrix or separate job definitions
Forgetting actions: write permissionCannot dispatch actions or create releasesExplicitly set required permissions
Cache misses due to wrong keySlow CI from re-downloading dependenciesUse hash of lockfile as cache key with restore-keys fallback
Service container health checks failingFlaky integration testsAdd 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=max

Comparison with Alternatives

FeatureGitHub ActionsCircleCIGitLab CIJenkins
Free minutes2,000/month6,000/month400/monthUnlimited
Marketplace20,000+ actions3,500+ orbsTemplates1,800+ plugins
Container jobsYesYesYesPlugin
Matrix buildsNative YAMLParameterizedParallelAxis plugin
OIDC authYesNoYesNo
Self-hostedYesYesYesPrimary model
Reusable workflowsYesYesYesShared libraries
Runner OS optionsLinux, macOS, WindowsLinux, macOS, Windows, GPULinux, macOS, WindowsAny

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: bash

Workflow 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: false

Testing 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/*.yml

Future 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.