Back to articles

CI/CD Face-off: GitHub Actions vs Vercel

By tvignoli DevOps Folio·Published on July 12, 2024

When teams debate GitHub Actions (GHA) versus Vercel they usually focus on price or a single feature. The real question is: "What workflow are we optimising for?" I have deployed monorepos with tens of microservices on Actions and launched Next.js platforms exclusively on Vercel. After managing CI/CD pipelines for fintech, e-commerce, and SaaS organizations processing thousands of deployments monthly, I've distilled the patterns that deliver velocity, reliability, and cost efficiency at scale.

Capabilities & Developer Experience

GHA provides building blocks: reusable workflows, matrix builds, custom actions, self-hosted runners, OIDC federation with clouds, environment protection rules. Vercel focuses on zero-config DX: automatic preview URLs per PR, built-in image optimisation, Edge Functions, and env manager. If you need to run Terraform, build Docker images, and publish Helm charts, Actions is your friend. If you ship serverless frontends and want deploy-to-preview in seconds, Vercel is unbeatable.

GitHub Actions excels at complex orchestration. You can define multi-stage pipelines with conditional logic, parallel jobs, and dependencies between steps. The YAML syntax is verbose but powerful—you can express virtually any CI/CD pattern. Vercel's strength is abstraction: it detects your framework (Next.js, Remix, Astro), configures build settings automatically, and generates preview URLs without configuration. This simplicity comes at the cost of flexibility: you can't easily customize build steps or run arbitrary scripts.

# GitHub Actions: Complex multi-service pipeline
name: Full Stack CI/CD
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  AWS_REGION: us-east-1
  NODE_VERSION: '20.x'

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service: [api, worker, scheduler]
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          cache-dependency-path: ${{ matrix.service }}/package-lock.json
      
      - name: Install dependencies
        working-directory: ${{ matrix.service }}
        run: npm ci
      
      - name: Run linter
        working-directory: ${{ matrix.service }}
        run: npm run lint
      
      - name: Run tests
        working-directory: ${{ matrix.service }}
        run: npm test -- --coverage
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ${{ matrix.service }}/coverage/lcov.info

  build-containers:
    needs: lint-and-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.AWS_ECR_REGISTRY }}/api:${{ github.sha }}
            ${{ secrets.AWS_ECR_REGISTRY }}/api:latest
          cache-from: type=registry,ref=${{ secrets.AWS_ECR_REGISTRY }}/api:buildcache
          cache-to: type=registry,ref=${{ secrets.AWS_ECR_REGISTRY }}/api:buildcache,mode=max

  deploy-infrastructure:
    needs: build-containers
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.6.0
      
      - name: Terraform Plan
        working-directory: infrastructure
        run: terraform plan -out=tfplan
      
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main'
        working-directory: infrastructure
        run: terraform apply -auto-approve tfplan

  deploy-services:
    needs: [build-containers, deploy-infrastructure]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service: [api, worker]
    steps:
      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ matrix.service }}/task-definition.json
          service: ${{ matrix.service }}-service
          cluster: production-cluster
          wait-for-service-stability: true
# Vercel: Zero-config Next.js deployment
# vercel.json (optional - Vercel auto-detects most settings)
{
  "buildCommand": "npm run build",
  "outputDirectory": ".next",
  "framework": "nextjs",
  "regions": ["iad1"],
  "env": {
    "DATABASE_URL": "@database-url",
    "API_KEY": "@api-key"
  },
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        }
      ]
    }
  ]
}

# That's it. Every PR gets a preview URL automatically.
# No YAML, no configuration needed for standard Next.js apps.

Real-World Use Cases & Case Studies

Case study #1: A fintech startup with a monorepo containing 15 microservices (Node.js APIs, Python data pipelines, Terraform infrastructure) chose GitHub Actions. They needed to run integration tests across services, build Docker images for each service, deploy to ECS, and run infrastructure updates. Actions' matrix builds and job dependencies enabled parallel testing while maintaining service deployment order. Cost: ~$200/month for 2,000 build minutes. The same setup on Vercel would require custom build scripts and wouldn't support Docker builds natively.

Case study #2: A marketing agency builds client websites using Next.js. They deploy 50+ sites monthly, each requiring instant preview URLs for client approval. Vercel's automatic preview generation and Edge Functions for A/B testing made it the clear choice. Build time: 2-3 minutes per site. Preview URLs are generated automatically on every PR. Cost: $20/month per team member plus usage. Migrating to Actions would require custom preview URL generation, S3/CloudFront setup, and significantly more configuration.

# GitHub Actions: Self-hosted runners for cost optimization
name: Build with Self-Hosted Runner
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: self-hosted  # EC2 spot instance
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20.x'
      
      - name: Cache dependencies
        uses: actions/cache@v3
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-
      
      - name: Install and build
        run: |
          npm ci
          npm run build
      
      - name: Upload artifacts
        uses: actions/upload-artifact@v3
        with:
          name: dist
          path: dist/

# Cost optimization: Self-hosted runners on EC2 spot instances
# - GitHub Actions: $0.008/minute for Linux
# - EC2 t3.medium spot: ~$0.01/hour = $0.00017/minute
# Savings: ~95% for long-running builds

Integrations, Performance, and Cost

Both integrate with GitHub repos. Actions can also run from GitLab/Bitbucket using webhooks. Actions billing is per runner-minute (with multipliers per OS). Optimisation trick: attach ephemeral EC2 spot instances as self-hosted runners for GPU or ARM workloads. Vercel bills build minutes plus runtime resources, and the hosting bill is tied to the same platform, simplifying procurement for frontend teams.

GitHub Actions pricing: $0.008/minute for Linux, $0.016/minute for Windows, $0.08/minute for macOS. Free tier: 2,000 minutes/month for private repos. Self-hosted runners are free but require infrastructure management. Vercel pricing: Free tier includes 100GB bandwidth, then $20/user/month for Pro (unlimited bandwidth, team features). Enterprise pricing is custom. Build minutes are included in Pro plan; additional minutes cost $40 per 1,000 minutes.

Performance comparison: GitHub Actions runners are ephemeral—each job starts fresh, which ensures consistency but adds startup overhead. Vercel's build cache is more aggressive, often resulting in faster incremental builds. For monorepos, Actions can parallelize jobs across services, while Vercel builds sequentially unless you configure build filters.

# GitHub Actions: OIDC authentication for AWS (no long-lived credentials)
name: Deploy to AWS
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
          aws-region: us-east-1
      
      - name: Deploy to S3
        run: |
          aws s3 sync ./dist s3://my-bucket --delete
      
      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation             --distribution-id E1234567890             --paths "/*"

# Security benefit: No AWS access keys stored in secrets
# Uses OIDC federation - credentials expire after job completion
// Vercel: Edge Functions for A/B testing
// api/ab-test.js
export default async function handler(req, res) {
  const variant = req.cookies.variant || (Math.random() > 0.5 ? 'A' : 'B');
  
  // Set cookie for consistent experience
  res.setHeader('Set-Cookie', `variant=${variant}; Path=/; Max-Age=86400`);
  
  // Return variant-specific content
  const content = {
    A: { headline: 'Welcome to Our Site', cta: 'Get Started' },
    B: { headline: 'Transform Your Business', cta: 'Start Free Trial' }
  };
  
  return res.json(content[variant]);
}

// Deployed automatically to Edge Network
// Zero configuration, runs at <50ms latency globally

Advanced Patterns & Optimizations

GitHub Actions: Use matrix builds for testing across multiple Node.js versions, operating systems, or service combinations. Implement job dependencies to ensure services deploy in order. Use artifacts to pass build outputs between jobs. Leverage reusable workflows to avoid YAML duplication across repositories. For monorepos, use path filters to trigger jobs only when relevant files change.

Vercel: Configure build filters to only rebuild changed packages in monorepos. Use Edge Middleware for request rewriting, authentication, and A/B testing. Leverage Incremental Static Regeneration (ISR) for dynamic content that doesn't need real-time updates. Use Vercel Analytics to track Core Web Vitals and identify performance bottlenecks.

# GitHub Actions: Monorepo optimization with path filters
name: Monorepo CI
on:
  push:
    paths:
      - 'services/api/**'
      - 'services/shared/**'
      - '.github/workflows/**'

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      api: ${{ steps.filter.outputs.api }}
      worker: ${{ steps.filter.outputs.worker }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            api:
              - 'services/api/**'
              - 'services/shared/**'
            worker:
              - 'services/worker/**'
              - 'services/shared/**'

  test-api:
    needs: detect-changes
    if: needs.detect-changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cd services/api && npm test

  test-worker:
    needs: detect-changes
    if: needs.detect-changes.outputs.worker == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cd services/worker && npm test
// Vercel: Edge Middleware for request handling
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // A/B testing
  const variant = request.cookies.get('variant')?.value || 
    (Math.random() > 0.5 ? 'A' : 'B');
  
  // Geo-based routing
  const country = request.geo?.country || 'US';
  if (country === 'GB') {
    return NextResponse.rewrite(new URL('/uk', request.url));
  }
  
  // Authentication check
  const token = request.cookies.get('auth-token');
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  
  // Add custom headers
  const response = NextResponse.next();
  response.headers.set('X-Country', country);
  response.headers.set('X-Variant', variant);
  
  return response;
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

// Runs at Edge locations globally, <50ms latency

Hybrid Approaches: Best of Both Worlds

Many organizations use both platforms: Actions for backend CI/CD and infrastructure deployments, Vercel for frontend previews and hosting. The pattern: run tests and build backend services in Actions, then trigger Vercel deployment via webhook after backend tests pass. This keeps ownership clear: platform team owns Actions workflows, frontend team owns Vercel configuration.

Example workflow: Backend API changes trigger Actions workflow that runs tests, builds Docker images, and deploys to ECS. On success, Actions calls Vercel's deployment API to rebuild the frontend (which consumes the updated API). Frontend PRs trigger Vercel previews immediately, while backend changes require full Actions pipeline before frontend rebuild.

# GitHub Actions: Trigger Vercel after backend deployment
name: Full Stack Deployment
on:
  push:
    branches: [main]
    paths:
      - 'backend/**'
      - 'frontend/**'

jobs:
  deploy-backend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Run backend tests
        run: cd backend && npm test
      
      - name: Build and deploy backend
        run: |
          cd backend
          docker build -t api:latest .
          # Deploy to ECS...
      
      - name: Trigger Vercel deployment
        run: |
          curl -X POST https://api.vercel.com/v1/integrations/deploy/your-project-id             -H "Authorization: Bearer ${{ secrets.VERCEL_TOKEN }}"             -d '{"gitRef": "${{ github.sha }}"}'

  deploy-frontend:
    if: contains(github.event.head_commit.message, '[deploy-frontend]')
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Vercel
        run: |
          curl -X POST https://api.vercel.com/v1/integrations/deploy/your-project-id             -H "Authorization: Bearer ${{ secrets.VERCEL_TOKEN }}"

Choosing the Right Tool

Pick Actions when you own backend APIs, Kafka pipelines, or IaC codebases. It shines with policy checks, approvals, and multi-cloud deployments. Choose Vercel when the scope is mostly frontend/serverless and you want instant previews for stakeholders. Many organisations run both: Actions for heavy CI, Vercel for DX-friendly deployments of the frontend. Use webhooks to chain the two (Actions notifies Vercel after backend tests pass), keeping ownership clear.

Decision matrix: If you need Docker builds, multi-cloud deployments, or complex orchestration → GitHub Actions. If you're building Next.js/React sites and want zero-config previews → Vercel. If you have both backend and frontend → use both, with Actions for backend CI/CD and Vercel for frontend hosting. Cost consideration: Actions is cheaper for high-volume builds (especially with self-hosted runners), Vercel is more cost-effective when hosting is included in the same platform.

Migration path: If starting with Vercel and needing more control, you can export build artifacts and deploy them via Actions to S3/CloudFront. If starting with Actions and wanting faster frontend previews, you can add Vercel as a secondary deployment target. The key is not forcing one tool to do everything—use each platform for what it does best.

    Thomas Vignoli - Senior DevOps Engineer Portfolio