CI/CD Face-off: GitHub Actions vs Vercel
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 buildsIntegrations, 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 globallyAdvanced 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 latencyHybrid 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.