CI/CD Pipeline for Frontend Applications: The Complete 2025 Guide
After years of manually deploying frontend applications and watching production break at 2 AM, I learned that a solid CI/CD pipeline isn't luxury—it's survival. Last month, our team prevented 12 potential production issues through automated checks before they ever reached users.
Here's everything I've learned about building bulletproof CI/CD pipelines for modern frontend applications, including the mistakes that cost us downtime and the strategies that saved our sanity.
The Frontend CI/CD Pipeline That Actually Works
Most tutorials show you basic build-and-deploy workflows. Real production pipelines need to handle dependency vulnerabilities, performance regressions, security scans, and rollback strategies. Here's the pipeline architecture that's served us through 500+ production deployments:
# .github/workflows/production.yml
name: Production Deployment
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: '20.x'
CACHE_VERSION: 'v1'
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache node modules
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ env.CACHE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ env.CACHE_VERSION }}-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run security audit
run: npm audit --audit-level moderate
- name: Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
quality-checks:
runs-on: ubuntu-latest
needs: security-scan
steps:
- uses: actions/checkout@v4
- name: Cache node modules
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ env.CACHE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ env.CACHE_VERSION }}-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Type checking
run: npm run type-check
- name: Run tests
run: npm run test:ci
env:
CI: true
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
build-and-test:
runs-on: ubuntu-latest
needs: [security-scan, quality-checks]
strategy:
matrix:
environment: [staging, production]
steps:
- uses: actions/checkout@v4
- name: Cache node modules
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ env.CACHE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ env.CACHE_VERSION }}-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build:${{ matrix.environment }}
env:
NODE_ENV: ${{ matrix.environment }}
NEXT_PUBLIC_API_URL: ${{ secrets[format('API_URL_{0}', matrix.environment)] }}
- name: Run E2E tests
uses: cypress-io/github-action@v6
with:
start: npm start
wait-on: 'http://localhost:3000'
browser: chrome
record: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: build-${{ matrix.environment }}
path: |
.next/
out/
!node_modules/
retention-days: 7
performance-audit:
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v3
with:
name: build-production
- name: Lighthouse CI
uses: treosh/lighthouse-ci-action@v10
with:
configPath: './lighthouserc.json'
uploadArtifacts: true
temporaryPublicStorage: true
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
deploy:
runs-on: ubuntu-latest
needs: [build-and-test, performance-audit]
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v3
with:
name: build-production
- name: Deploy to production
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID }}
vercel-project-id: ${{ secrets.PROJECT_ID }}
vercel-args: '--prod'
- name: Update deployment status
uses: chrnorm/deployment-status@v2
with:
token: '${{ github.token }}'
state: 'success'
deployment-id: ${{ steps.deploy.outputs.deployment-id }}
Performance Optimization That Actually Matters
The difference between a 2-minute and 8-minute pipeline isn't just developer productivity—it's the difference between fixing critical bugs in minutes versus hours. Here are the optimizations that cut our pipeline time by 60%:
Smart Caching Strategy
# Advanced caching configuration
- name: Cache dependencies and build outputs
uses: actions/cache@v3
with:
path: |
~/.npm
.next/cache
node_modules/.cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
${{ runner.os }}-nextjs-
# Cache Docker layers for containerized builds
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
Parallel Job Execution
# Parallel matrix builds for different environments
strategy:
matrix:
node-version: [18, 20]
environment: [staging, production]
include:
- node-version: 20
environment: production
is-primary: true
fail-fast: false # Continue other jobs even if one fails
# Parallel test execution
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4] # Split tests into 4 parallel jobs
steps:
- name: Run test shard
run: npm test -- --shard=${{ matrix.shard }}/4
Optimized Docker Builds
# Multi-stage Dockerfile for frontend apps
FROM node:20-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Security Integration That Prevents Disasters
After a dependency vulnerability made it to production and exposed user data, security became non-negotiable in our pipeline. Here's the security-first approach that's caught 47 vulnerabilities before they reached users:
Comprehensive Security Scanning
# Complete security workflow
security-pipeline:
runs-on: ubuntu-latest
steps:
# Dependency vulnerability scanning
- name: Run npm audit
run: |
npm audit --audit-level moderate --json > audit-results.json
if [ $(jq '.metadata.vulnerabilities.moderate + .metadata.vulnerabilities.high + .metadata.vulnerabilities.critical' audit-results.json) -gt 0 ]; then
echo "Security vulnerabilities found"
exit 1
fi
# Advanced vulnerability scanning with Snyk
- name: Snyk vulnerability scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=medium --fail-on=upgradable
# Static Application Security Testing (SAST)
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: javascript
queries: security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
# License compliance checking
- name: License compliance
run: npx license-checker --production --excludePrivatePackages --json > licenses.json
# Secrets scanning
- name: Secrets detection
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: main
head: HEAD
Secure Secrets Management
# Production secrets configuration
deploy:
environment:
name: production
secrets:
API_KEY: ${{ secrets.PROD_API_KEY }}
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }}
steps:
- name: Validate required secrets
run: |
if [ -z "${{ secrets.PROD_API_KEY }}" ]; then
echo "Missing required secret: PROD_API_KEY"
exit 1
fi
- name: Deploy with secret injection
run: |
echo "API_KEY=${{ secrets.PROD_API_KEY }}" >> .env.production.local
echo "DATABASE_URL=${{ secrets.PROD_DATABASE_URL }}" >> .env.production.local
npm run deploy:production
rm .env.production.local # Clean up secrets file
Advanced Deployment Strategies
Simple deployments work until they don't. Here are the deployment strategies that saved us from 3 AM emergency rollbacks:
Blue-Green Deployment with Health Checks
# Blue-green deployment workflow
blue-green-deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to staging (green) environment
run: |
# Deploy to green environment
vercel deploy --name=app-green --env production
- name: Run health checks on green
run: |
# Wait for deployment to be ready
sleep 30
# Run health checks
curl -f "https://app-green.vercel.app/api/health" || exit 1
# Run smoke tests
npm run test:smoke -- --baseUrl=https://app-green.vercel.app
- name: Switch traffic to green
if: success()
run: |
# Update DNS or load balancer to point to green
vercel alias https://app-green.vercel.app app.example.com
- name: Keep blue as fallback
run: |
# Keep the previous version available for quick rollback
echo "Blue environment kept at: https://app-blue.vercel.app"
Feature Flag Integration
// Feature flag configuration for gradual rollouts
// lib/feature-flags.ts
interface FeatureConfig {
rolloutPercentage: number;
enabledEnvironments: string[];
userSegments?: string[];
}
export const features: Record<string, FeatureConfig> = {
newCheckoutFlow: {
rolloutPercentage: 25, // Start with 25% of users
enabledEnvironments: ['production'],
userSegments: ['beta-users'],
},
enhancedSearch: {
rolloutPercentage: 0, // Disabled initially
enabledEnvironments: ['staging'],
},
};
export function isFeatureEnabled(
featureKey: string,
userId?: string,
environment = process.env.NODE_ENV
): boolean {
const config = features[featureKey];
if (!config) return false;
// Check environment
if (!config.enabledEnvironments.includes(environment)) {
return false;
}
// Check user segment
if (config.userSegments && userId) {
const userHash = hashUserId(userId);
const segment = userHash % 100;
return segment < config.rolloutPercentage;
}
return Math.random() * 100 < config.rolloutPercentage;
}
# Feature flag deployment workflow
feature-flag-deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy with feature flags
run: |
# Deploy application with features disabled
FEATURE_NEW_CHECKOUT=false npm run build
vercel deploy --prod
- name: Gradually enable features
run: |
# Use external feature flag service or API
curl -X POST "${{ secrets.FEATURE_FLAG_API }}/features/newCheckoutFlow" \
-H "Authorization: Bearer ${{ secrets.FF_TOKEN }}" \
-d '{"rolloutPercentage": 10}'
# Monitor for 30 minutes
sleep 1800
# If no errors, increase to 50%
if [ $(curl -s "${{ secrets.MONITORING_API }}/error-rate" | jq '.errorRate') -lt 0.01 ]; then
curl -X POST "${{ secrets.FEATURE_FLAG_API }}/features/newCheckoutFlow" \
-H "Authorization: Bearer ${{ secrets.FF_TOKEN }}" \
-d '{"rolloutPercentage": 50}'
fi
Automated Rollback Strategy
# Monitoring and rollback workflow
monitoring-rollback:
runs-on: ubuntu-latest
needs: deploy
steps:
- name: Monitor deployment health
id: health-check
run: |
# Monitor for 10 minutes after deployment
for i in {1..20}; do
ERROR_RATE=$(curl -s "${{ secrets.MONITORING_API }}/metrics" | jq '.errorRate')
RESPONSE_TIME=$(curl -s "${{ secrets.MONITORING_API }}/metrics" | jq '.avgResponseTime')
echo "Error rate: $ERROR_RATE%, Response time: ${RESPONSE_TIME}ms"
# Check if metrics exceed thresholds
if (( $(echo "$ERROR_RATE > 5" | bc -l) )); then
echo "High error rate detected: $ERROR_RATE%"
echo "should_rollback=true" >> $GITHUB_OUTPUT
break
fi
if (( $(echo "$RESPONSE_TIME > 2000" | bc -l) )); then
echo "High response time detected: ${RESPONSE_TIME}ms"
echo "should_rollback=true" >> $GITHUB_OUTPUT
break
fi
sleep 30
done
- name: Automatic rollback
if: steps.health-check.outputs.should_rollback == 'true'
run: |
echo "Rolling back deployment due to health check failure"
# Get previous deployment ID
PREVIOUS_DEPLOYMENT=$(curl -s "${{ secrets.VERCEL_API }}/deployments" \
-H "Authorization: Bearer ${{ secrets.VERCEL_TOKEN }}" | \
jq -r '.deployments[] | select(.state == "READY" and .meta.githubCommitRef != "${{ github.sha }}") | .uid' | head -1)
# Rollback to previous deployment
vercel alias $PREVIOUS_DEPLOYMENT app.example.com
# Notify team
curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
-H 'Content-type: application/json' \
--data '{"text":"🚨 Auto-rollback triggered for deployment ${{ github.sha }} due to health check failure"}'
Performance Monitoring Integration
The deployment isn't complete until you know it's actually working for users. Here's the monitoring setup that gives us confidence in every release:
Lighthouse CI Configuration
// lighthouserc.json
{
"ci": {
"collect": {
"url": ["http://localhost:3000", "http://localhost:3000/products"],
"startServerCommand": "npm start",
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", {"minScore": 0.8}],
"categories:accessibility": ["error", {"minScore": 0.9}],
"categories:best-practices": ["error", {"minScore": 0.8}],
"categories:seo": ["error", {"minScore": 0.8}],
"first-contentful-paint": ["error", {"maxNumericValue": 2000}],
"largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
Real-Time Error Monitoring
// lib/monitoring.ts
import { captureException, addBreadcrumb } from '@sentry/nextjs';
export class DeploymentMonitor {
private deploymentId: string;
private startTime: number;
constructor(deploymentId: string) {
this.deploymentId = deploymentId;
this.startTime = Date.now();
}
async checkHealth(): Promise<{ healthy: boolean; metrics: any }> {
try {
// Check critical API endpoints
const apiHealth = await fetch('/api/health');
// Check database connectivity
const dbHealth = await fetch('/api/db-health');
// Check external service dependencies
const servicesHealth = await this.checkExternalServices();
const metrics = {
deploymentId: this.deploymentId,
uptime: Date.now() - this.startTime,
apiStatus: apiHealth.status,
dbStatus: dbHealth.status,
externalServices: servicesHealth,
timestamp: new Date().toISOString(),
};
const healthy = apiHealth.ok && dbHealth.ok && servicesHealth.every(s => s.healthy);
// Send metrics to monitoring service
await this.sendMetrics(metrics);
return { healthy, metrics };
} catch (error) {
captureException(error, {
tags: { deploymentId: this.deploymentId },
contexts: { deployment: { id: this.deploymentId } }
});
return { healthy: false, metrics: null };
}
}
private async checkExternalServices() {
const services = [
{ name: 'payment-gateway', url: process.env.PAYMENT_API_URL },
{ name: 'analytics', url: process.env.ANALYTICS_API_URL },
{ name: 'auth-service', url: process.env.AUTH_API_URL },
];
return Promise.all(
services.map(async (service) => {
try {
const response = await fetch(`${service.url}/health`, {
timeout: 5000
});
return { name: service.name, healthy: response.ok };
} catch {
return { name: service.name, healthy: false };
}
})
);
}
private async sendMetrics(metrics: any) {
// Send to your monitoring service (DataDog, New Relic, etc.)
await fetch(process.env.METRICS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metrics),
});
}
}
Framework-Specific Optimizations
Different frameworks need different pipeline approaches. Here's what works for the most common setups:
Next.js Pipeline Configuration
# .github/workflows/nextjs.yml
name: Next.js Production Pipeline
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
# Next.js specific optimizations
- name: Build with analysis
run: |
npm run build
npx @next/bundle-analyzer
env:
ANALYZE: true
NODE_ENV: production
- name: Export static files (if using static export)
run: npm run export
if: env.STATIC_EXPORT == 'true'
- name: Test build output
run: |
# Test that critical pages exist
test -f .next/static/css/*.css
test -f .next/static/js/*.js
# Check bundle size
BUNDLE_SIZE=$(find .next/static -name "*.js" -exec wc -c {} + | tail -1 | awk '{print $1}')
if [ $BUNDLE_SIZE -gt 1000000 ]; then # 1MB limit
echo "Bundle size too large: $BUNDLE_SIZE bytes"
exit 1
fi
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID }}
vercel-project-id: ${{ secrets.PROJECT_ID }}
vercel-args: '--prod'
React SPA Pipeline
# For Create React App or Vite projects
name: React SPA Pipeline
on:
push:
branches: [main]
jobs:
test-build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm test -- --coverage --watchAll=false
env:
CI: true
- name: Build for production
run: |
npm run build
# Validate build output
test -d build/
test -f build/index.html
test -f build/static/css/*.css
test -f build/static/js/*.js
# Bundle size analysis
- name: Analyze bundle size
run: |
npx webpack-bundle-analyzer build/static/js/*.js --mode server --host 0.0.0.0 --port 8888 &
sleep 5
curl -f http://localhost:8888 > /dev/null
- name: Deploy to S3/CloudFront
run: |
aws s3 sync build/ s3://${{ secrets.S3_BUCKET }} --delete
aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_ID }} --paths "/*"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
Common Pitfalls and How to Avoid Them
After 500+ deployments, these are the mistakes that will bite you and how to prevent them:
Environment Variable Leakage
# ❌ Wrong - secrets can leak in logs
- name: Build with secrets
run: npm run build
env:
SECRET_KEY: ${{ secrets.SECRET_KEY }}
API_URL: ${{ secrets.API_URL }}
# ✅ Correct - validate and inject safely
- name: Validate required secrets
run: |
required_secrets=("SECRET_KEY" "API_URL")
for secret in "${required_secrets[@]}"; do
if [ -z "${!secret}" ]; then
echo "Missing required secret: $secret"
exit 1
fi
done
- name: Build with secrets
run: |
# Create temporary env file
cat > .env.production.local << EOF
SECRET_KEY=${{ secrets.SECRET_KEY }}
API_URL=${{ secrets.API_URL }}
EOF
npm run build
# Clean up immediately
rm -f .env.production.local
Cache Invalidation Issues
# ❌ Wrong - cache never invalidates properly
- name: Cache dependencies
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# ✅ Correct - include content hash for proper invalidation
- name: Cache with proper invalidation
uses: actions/cache@v3
with:
path: |
~/.npm
node_modules
.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx', '!node_modules/**') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
${{ runner.os }}-nextjs-
Test Flakiness in CI
// ❌ Wrong - tests that fail randomly in CI
test('should load user data', async () => {
render(<UserProfile />);
await screen.findByText('John Doe'); // Flaky - depends on API timing
});
// ✅ Correct - reliable testing with proper mocking
test('should load user data', async () => {
// Mock the API response
server.use(
rest.get('/api/users/current', (req, res, ctx) => {
return res(ctx.json({ name: 'John Doe', email: 'john@example.com' }));
})
);
render(<UserProfile />);
// Wait for loading state to disappear
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
// Assert on the expected content
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
Setting up a robust CI/CD pipeline takes time upfront, but it's the difference between deploying with confidence and hoping nothing breaks. Start with the security scanning and basic build validation, then gradually add performance monitoring and advanced deployment strategies.
The pipeline configuration I've shared has prevented more production issues than I can count, and the peace of mind it provides is worth every hour spent setting it up. Your future self—and your users—will thank you for building it right from the start.