CI/CD That Actually Ships: Our Practical Pipeline for Web Apps
CI/CD doesn't need to be complicated. Learn to build a practical pipeline with GitHub Actions that tests, builds, and deploys web apps safely and quickly.
A practical CI/CD pipeline has 5 stages: lint/typecheck (fail fast), test (unit + integration + E2E in parallel), build (Docker image + artifacts), deploy to staging (automated with smoke tests), and deploy to production (manual approval + health checks + automatic rollback). Automate everything except production approval. Use GitHub Actions for free up to 2,000 minutes/month. Total setup time: 4-6 hours. Result: deploy to production in 8-12 minutes with confidence.
Pipeline Overview
A good CI/CD pipeline has clear stages that give fast feedback.Our Pipeline (8-12 minutes total):Stage 1: Lint & Typecheck (1 min) → ESLint, Prettier, TypeScript → Fail fast if code quality issues Stage 2: Test (3-5 min) → Unit tests (parallel) → Integration tests (parallel) → E2E tests (if needed) Stage 3: Build (2-3 min) → Production build → Create Docker image → Upload artifacts Stage 4: Deploy to Staging (2 min) → Automated deployment → Smoke tests → Integration tests Stage 5: Deploy to Production (2 min) → Manual approval required → Blue-green deployment → Health checks → Automatic rollback if unhealthyThe Visual:
1 [Commit] 2 ↓ 3 [Lint/Type] ← 1 min, fail fast 4 ↓ 5 [Test Suite] ← 3-5 min, parallel 6 ↓ 7 [Build] ← 2-3 min 8 ↓ 9 [Deploy Staging] ← 2 min, automatic 10 ↓ 11 [Run Tests on Staging] ← 1 min 12 ↓ 13 [Approve?] ← Human decision 14 ↓ 15 [Deploy Production] ← 2 min, health checks 16 ↓ 17 [Monitor] ← Automatic rollback if issues
Key Principles:FAST FEEDBACK: Developers know within 5 minutes if their code breaks somethingAUTOMATED BY DEFAULT: Zero manual steps until production approvalSTAGING = PRODUCTION: Same environment, same data structure, same infrastructureSAFE PRODUCTION DEPLOYS: Health checks + automatic rollbackONE-CLICK ROLLBACK: When things go wrong, fix it in 30 secondsThis pipeline has deployed thousands of times with 99.8% success rate.
Stage 1: Linting and Type Checking
Fail fast on code quality issues. Don't waste time running tests if the code doesn't even compile.What We Check:1. ESLint - Code quality rules2. Prettier - Formatting consistency 3. TypeScript - Type errors4. Import sorting - Keep imports organizedWhy This Stage is First:These checks take 30-60 seconds. If they fail, no point running 5 minutes of tests.Implementation:
1 # .github/workflows/ci.yml 2 name: CI 3 4 on: 5 push: 6 branches: [main, develop] 7 pull_request: 8 9 jobs: 10 lint: 11 runs-on: ubuntu-latest 12 steps: 13 - uses: actions/checkout@v3 14 15 - name: Setup Node.js 16 uses: actions/setup-node@v3 17 with: 18 node-version: '18' 19 cache: 'npm' 20 21 - name: Install dependencies 22 run: npm ci 23 24 - name: Run ESLint 25 run: npm run lint 26 27 - name: Check Prettier 28 run: npm run format:check 29 30 - name: TypeScript Check 31 run: npm run typecheck
1 {
2 "scripts": {
3 "lint": "eslint . --ext .ts,.tsx --max-warnings 0",
4 "format:check": "prettier --check 'src/**/*.{ts,tsx,json,css}'",
5 "typecheck": "tsc --noEmit"
6 }
7 }The --max-warnings 0 flag is important. Warnings are technical debt. Don't let them accumulate.ESLint Configuration:
1 // .eslintrc.js
2 module.exports = {
3 extends: [
4 'eslint:recommended',
5 'plugin:@typescript-eslint/recommended',
6 'plugin:react-hooks/recommended'
7 ],
8 rules: {
9 'no-console': 'warn', // Flag console.logs
10 'no-unused-vars': 'error',
11 '@typescript-eslint/no-explicit-any': 'error' // Ban 'any' type
12 }
13 };Result:If someone pushes code with lint errors, the pipeline fails in 60 seconds. They fix it before wasting CI time on tests.This alone saves 20-30 hours of CI time per month.
Stage 2: Testing
Run tests in parallel for speed. Prioritize tests that catch real bugs.Test Types (in order of value):1. Integration Tests (most valuable) - Test API endpoints - Test database interactions - Test critical user flows 2. Unit Tests (fast, good coverage) - Test business logic - Test utility functions - Test complex calculations3. E2E Tests (expensive, use sparingly) - Test critical user journeys - Test payment flows - Test signup/loginThe Ratio:70% Integration Tests25% Unit Tests5% E2E TestsThis is opposite of the "testing pyramid" but catches more real bugs.Implementation:
1 test: 2 runs-on: ubuntu-latest 3 needs: lint 4 5 services: 6 postgres: 7 image: postgres:15 8 env: 9 POSTGRES_PASSWORD: test 10 POSTGRES_DB: test 11 options: >- 12 --health-cmd pg_isready 13 --health-interval 10s 14 --health-timeout 5s 15 --health-retries 5 16 17 steps: 18 - uses: actions/checkout@v3 19 20 - name: Setup Node.js 21 uses: actions/setup-node@v3 22 with: 23 node-version: '18' 24 cache: 'npm' 25 26 - name: Install dependencies 27 run: npm ci 28 29 - name: Run Unit Tests 30 run: npm run test:unit 31 32 - name: Run Integration Tests 33 run: npm run test:integration 34 env: 35 DATABASE_URL: postgresql://postgres:test@localhost:5432/test 36 37 - name: Upload Coverage 38 uses: codecov/codecov-action@v3 39 with: 40 files: ./coverage/coverage-final.json
1 {
2 "scripts": {
3 "test:unit": "jest --testPathPattern='.*.test.ts$' --maxWorkers=4",
4 "test:integration": "jest --testPathPattern='.*.integration.ts$' --runInBand",
5 "test:e2e": "playwright test"
6 }
7 }Coverage Requirements:Don't require 100% coverage. It's a vanity metric.Our thresholds:- Statements: 70%- Branches: 60%- Functions: 70%- Lines: 70%
1 // jest.config.js
2 module.exports = {
3 coverageThreshold: {
4 global: {
5 statements: 70,
6 branches: 60,
7 functions: 70,
8 lines: 70
9 }
10 }
11 };Focus on testing:- Business logic- Edge cases- Error handling- Integration pointsDon't test:- Trivial getters/setters- Third-party libraries- UI components (use visual tests instead)E2E Tests (Playwright):
1 // e2e/auth.spec.ts
2 import { test, expect } from '@playwright/test';
3
4 test('user can sign up and log in', async ({ page }) => {
5 // Sign up
6 await page.goto('/signup');
7 await page.fill('[name="email"]', '[email protected]');
8 await page.fill('[name="password"]', 'SecurePass123!');
9 await page.click('button[type="submit"]');
10
11 // Verify redirected to dashboard
12 await expect(page).toHaveURL('/dashboard');
13 await expect(page.locator('h1')).toContainText('Welcome');
14 });Run E2E only on critical paths:- User signup/login- Payment flow- Core feature workflowsDon't E2E test every page. Too slow, too flaky.
Stage 3: Build and Artifact Creation
Create production artifacts that can be deployed to any environment.What We Build:1. Optimized production bundle (minified, tree-shaken)2. Docker image (contains app + dependencies)3. Source maps (for debugging production)4. Build metadata (commit hash, timestamp)Implementation:
1 build:
2 runs-on: ubuntu-latest
3 needs: test
4
5 steps:
6 - uses: actions/checkout@v3
7
8 - name: Setup Node.js
9 uses: actions/setup-node@v3
10 with:
11 node-version: '18'
12 cache: 'npm'
13
14 - name: Install dependencies
15 run: npm ci
16
17 - name: Build Application
18 run: npm run build
19 env:
20 NODE_ENV: production
21
22 - name: Set up Docker Buildx
23 uses: docker/setup-buildx-action@v2
24
25 - name: Login to Container Registry
26 uses: docker/login-action@v2
27 with:
28 registry: ghcr.io
29 username: ${{ github.actor }}
30 password: ${{ secrets.GITHUB_TOKEN }}
31
32 - name: Extract metadata
33 id: meta
34 uses: docker/metadata-action@v4
35 with:
36 images: ghcr.io/${{ github.repository }}
37 tags: |
38 type=ref,event=branch
39 type=sha,prefix={{branch}}-
40 type=semver,pattern={{version}}
41
42 - name: Build and Push Docker Image
43 uses: docker/build-push-action@v4
44 with:
45 context: .
46 push: true
47 tags: ${{ steps.meta.outputs.tags }}
48 labels: ${{ steps.meta.outputs.labels }}
49 cache-from: type=gha
50 cache-to: type=gha,mode=max1 # Build stage 2 FROM node:18-alpine AS builder 3 4 WORKDIR /app 5 6 COPY package*.json ./ 7 RUN npm ci --only=production 8 9 COPY . . 10 RUN npm run build 11 12 # Production stage 13 FROM node:18-alpine 14 15 WORKDIR /app 16 17 COPY --from=builder /app/dist ./dist 18 COPY --from=builder /app/node_modules ./node_modules 19 COPY --from=builder /app/package.json ./ 20 21 EXPOSE 3000 22 23 CMD ["node", "dist/index.js"]
Multi-stage build keeps image small (~150MB vs 1GB+).Build Metadata:
1 // Inject at build time
2 const buildInfo = {
3 version: process.env.npm_package_version,
4 commit: process.env.GITHUB_SHA?.substring(0, 7),
5 buildTime: new Date().toISOString(),
6 branch: process.env.GITHUB_REF_NAME
7 };
8
9 // Expose via API
10 app.get('/health', (req, res) => {
11 res.json({
12 status: 'ok',
13 build: buildInfo
14 });
15 });This lets you verify which version is running in production.Artifact Storage:GitHub Container Registry (free for public repos):- Automatic cleanup of old images- Integrates with GitHub Actions- Pull from any environmentAlternative: Docker Hub, AWS ECR, Google Artifact Registry
Stage 4: Deploy to Staging
Staging is your production dress rehearsal. Automate it completely.Staging Environment Must:1. Mirror production infrastructure2. Use production-like data (sanitized)3. Connect to test versions of external services4. Run same Docker image that will go to productionImplementation:
1 deploy-staging:
2 runs-on: ubuntu-latest
3 needs: build
4 environment:
5 name: staging
6 url: https://staging.example.com
7
8 steps:
9 - name: Deploy to Staging
10 uses: appleboy/ssh-action@master
11 with:
12 host: ${{ secrets.STAGING_HOST }}
13 username: ${{ secrets.STAGING_USER }}
14 key: ${{ secrets.SSH_PRIVATE_KEY }}
15 script: |
16 cd /app
17 docker pull ghcr.io/${{ github.repository }}:main
18 docker-compose up -d --no-deps --build web
19
20 - name: Wait for Deployment
21 run: |
22 for i in {1..30}; do
23 if curl -f https://staging.example.com/health; then
24 echo "Staging is healthy"
25 exit 0
26 fi
27 echo "Waiting for staging... ($i/30)"
28 sleep 10
29 done
30 echo "Staging deployment failed"
31 exit 1
32
33 - name: Run Smoke Tests
34 run: |
35 npm run test:smoke
36 env:
37 BASE_URL: https://staging.example.comSmoke Tests:Quick tests that verify basic functionality:
1 // smoke.test.ts
2 describe('Staging Smoke Tests', () => {
3 const BASE_URL = process.env.BASE_URL;
4
5 test('API is responding', async () => {
6 const response = await fetch(`${BASE_URL}/health`);
7 expect(response.status).toBe(200);
8 });
9
10 test('Can create and retrieve data', async () => {
11 // POST data
12 const created = await fetch(`${BASE_URL}/api/items`, {
13 method: 'POST',
14 body: JSON.stringify({ name: 'test' })
15 });
16 expect(created.status).toBe(201);
17
18 // GET data
19 const retrieved = await fetch(`${BASE_URL}/api/items`);
20 expect(retrieved.status).toBe(200);
21 });
22
23 test('Authentication works', async () => {
24 const response = await fetch(`${BASE_URL}/api/auth/login`, {
25 method: 'POST',
26 body: JSON.stringify({
27 email: '[email protected]',
28 password: 'test123'
29 })
30 });
31 expect(response.status).toBe(200);
32 });
33 });Run these after every staging deploy. If they fail, don't proceed to production.Database Migrations:
1 - name: Run Migrations 2 run: | 3 docker exec app-staging npm run migrate
Always run migrations before deploying new code.Rollback if migrations fail:
1 # In deployment script 2 if ! docker exec app npm run migrate; then 3 echo "Migration failed, rolling back" 4 docker-compose down 5 docker-compose up -d --no-deps web:previous 6 exit 1 7 fi
Staging Environment Variables:
1 # .env.staging 2 NODE_ENV=production 3 DATABASE_URL=postgresql://...staging... 4 STRIPE_KEY=sk_test_... # Test API keys 5 SENDGRID_KEY=... # Sandbox mode 6 LOG_LEVEL=debug # More verbose in staging
Stage 5: Production Deployment
Production requires manual approval and careful rollout.Blue-Green Deployment Strategy:Two identical production environments:- BLUE (current version, receiving traffic)- GREEN (new version, being deployed)Process:1. Deploy to GREEN2. Run health checks on GREEN3. Switch traffic from BLUE to GREEN4. Keep BLUE running (for rollback)5. After 24h, shut down BLUEImplementation:
1 deploy-production:
2 runs-on: ubuntu-latest
3 needs: deploy-staging
4 environment:
5 name: production
6 url: https://example.com
7
8 steps:
9 - name: Wait for Approval
10 uses: trstringer/manual-approval@v1
11 with:
12 approvers: engineering-leads
13 minimum-approvals: 1
14
15 - name: Deploy to Green Environment
16 uses: appleboy/ssh-action@master
17 with:
18 host: ${{ secrets.PROD_HOST }}
19 username: ${{ secrets.PROD_USER }}
20 key: ${{ secrets.SSH_PRIVATE_KEY }}
21 script: |
22 cd /app
23
24 # Pull new image
25 docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
26
27 # Start green environment
28 docker-compose -f docker-compose.green.yml up -d
29
30 # Wait for health check
31 for i in {1..60}; do
32 if curl -f http://localhost:3001/health; then
33 echo "Green is healthy"
34 break
35 fi
36 sleep 5
37 done
38
39 - name: Run Health Checks
40 run: |
41 # Check green environment health
42 STATUS=$(curl -s http://prod-green.example.com/health | jq -r '.status')
43 if [ "$STATUS" != "ok" ]; then
44 echo "Health check failed"
45 exit 1
46 fi
47
48 # Check database connectivity
49 # Check external API connections
50 # Check queue workers
51
52 - name: Switch Traffic to Green
53 run: |
54 # Update load balancer to point to green
55 ssh $PROD_HOST "nginx -s reload"
56
57 - name: Monitor for Issues
58 run: |
59 # Watch error rates for 5 minutes
60 sleep 300
61 ERROR_RATE=$(curl -s https://api.monitoring.com/error-rate)
62 if [ "$ERROR_RATE" -gt "1" ]; then
63 echo "Error rate too high, rolling back"
64 exit 1
65 fi1 app.get('/health', async (req, res) => {
2 try {
3 // Check database
4 await db.query('SELECT 1');
5
6 // Check Redis
7 await redis.ping();
8
9 // Check external APIs
10 const stripe = await fetch('https://api.stripe.com/v1/health');
11 if (!stripe.ok) throw new Error('Stripe unhealthy');
12
13 res.json({
14 status: 'ok',
15 timestamp: new Date().toISOString(),
16 build: buildInfo
17 });
18 } catch (error) {
19 res.status(503).json({
20 status: 'unhealthy',
21 error: error.message
22 });
23 }
24 });1 - name: Automatic Rollback on Failure
2 if: failure()
3 run: |
4 echo "Deployment failed, rolling back"
5 ssh $PROD_HOST "docker-compose -f docker-compose.blue.yml up -d"
6 ssh $PROD_HOST "nginx -s reload"
7
8 # Notify team
9 curl -X POST $SLACK_WEBHOOK \
10 -d '{"text": "Production deployment failed and was rolled back"}'Manual Rollback (One Command):
1 # In production server 2 ./rollback.sh 3 4 # rollback.sh 5 #!/bin/bash 6 docker-compose -f docker-compose.blue.yml up -d 7 nginx -s reload 8 echo "Rolled back to blue environment"
Total time from "Approve" to "Traffic switched": 2-3 minutes
Monitoring and Alerts
Deploy doesn't end when code goes live. Monitor for issues.What to Monitor:1. Error Rate (spike indicates issues)2. Response Time (slowdown indicates problems)3. Traffic (sudden drop indicates outage)4. Database Connections (leak detection)5. Memory Usage (memory leak detection)6. Disk Space (prevent out-of-disk crashes)Post-Deploy Monitoring:
1 - name: Monitor Deployment
2 run: |
3 # Watch metrics for 10 minutes
4 for i in {1..60}; do
5 # Get current error rate
6 ERROR_RATE=$(curl -s https://api.datadog.com/api/v1/query?query=errors.rate | jq '.series[0].pointlist[-1][1]')
7
8 # Get baseline(yesterday same time)
9 BASELINE=$(curl -s https://api.datadog.com/api/v1/query?query=errors.rate{-1d} | jq '.series[0].pointlist[-1][1]')
10
11 # Alert if >50% increase
12 if [ "$(echo "$ERROR_RATE > $BASELINE * 1.5" | bc)" -eq 1 ]; then
13 echo "Error rate increased significantly"
14 exit 1
15 fi
16
17 sleep 10
18 doneError Tracking (Sentry):
1 import * as Sentry from '@sentry/node';
2
3 Sentry.init({
4 dsn: process.env.SENTRY_DSN,
5 environment: process.env.NODE_ENV,
6 release: process.env.GITHUB_SHA
7 });
8
9 // Track deployment
10 Sentry.captureMessage('Deployment succeeded', {
11 level: 'info',
12 tags: {
13 deployment: true,
14 version: process.env.GITHUB_SHA
15 }
16 });This creates a marker in Sentry. You can see if error rates spike after deployments.Alerts:
1 # Alert on deployment failure
2 - name: Notify on Failure
3 if: failure()
4 uses: 8398a7/action-slack@v3
5 with:
6 status: ${{ job.status }}
7 text: |
8 Deployment to production FAILED
9 Branch: ${{ github.ref }}
10 Commit: ${{ github.sha }}
11 Author: ${{ github.actor }}
12
13 Action: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
14 webhook_url: ${{ secrets.SLACK_WEBHOOK }}Alert channels:- Slack (for team notifications)- PagerDuty (for oncall alerts)- Email (for non-urgent issues)Success Notifications:
1 - name: Notify Success
2 uses: 8398a7/action-slack@v3
3 with:
4 status: success
5 text: |
6 ✅ Deployed to production successfully
7 Version: ${{ github.sha }}
8 Deployed by: ${{ github.actor }}Keep team informed of all production changes.
Complete GitHub Actions Example
Here's the full pipeline in one file:
1 # .github/workflows/deploy.yml
2 name: Deploy
3
4 on:
5 push:
6 branches: [main]
7 pull_request:
8
9 env:
10 NODE_VERSION: '18'
11
12 jobs:
13 # Stage 1: Lint
14 lint:
15 runs-on: ubuntu-latest
16 steps:
17 - uses: actions/checkout@v3
18 - uses: actions/setup-node@v3
19 with:
20 node-version: ${{ env.NODE_VERSION }}
21 cache: 'npm'
22 - run: npm ci
23 - run: npm run lint
24 - run: npm run format:check
25 - run: npm run typecheck
26
27 # Stage 2: Test
28 test:
29 runs-on: ubuntu-latest
30 needs: lint
31 services:
32 postgres:
33 image: postgres:15
34 env:
35 POSTGRES_PASSWORD: test
36 POSTGRES_DB: test
37 steps:
38 - uses: actions/checkout@v3
39 - uses: actions/setup-node@v3
40 with:
41 node-version: ${{ env.NODE_VERSION }}
42 cache: 'npm'
43 - run: npm ci
44 - run: npm run test
45 env:
46 DATABASE_URL: postgresql://postgres:test@localhost:5432/test
47
48 # Stage 3: Build
49 build:
50 runs-on: ubuntu-latest
51 needs: test
52 if: github.ref == 'refs/heads/main'
53 steps:
54 - uses: actions/checkout@v3
55 - uses: actions/setup-node@v3
56 with:
57 node-version: ${{ env.NODE_VERSION }}
58 cache: 'npm'
59 - run: npm ci
60 - run: npm run build
61
62 - uses: docker/setup-buildx-action@v2
63 - uses: docker/login-action@v2
64 with:
65 registry: ghcr.io
66 username: ${{ github.actor }}
67 password: ${{ secrets.GITHUB_TOKEN }}
68
69 - uses: docker/build-push-action@v4
70 with:
71 context: .
72 push: true
73 tags: ghcr.io/${{ github.repository }}:main
74 cache-from: type=gha
75 cache-to: type=gha,mode=max
76
77 # Stage 4: Deploy Staging
78 deploy-staging:
79 runs-on: ubuntu-latest
80 needs: build
81 environment:
82 name: staging
83 url: https://staging.example.com
84 steps:
85 - uses: appleboy/ssh-action@master
86 with:
87 host: ${{ secrets.STAGING_HOST }}
88 username: deploy
89 key: ${{ secrets.SSH_KEY }}
90 script: |
91 cd /app
92 docker-compose pull
93 docker-compose up -d
94
95 - name: Health Check
96 run: |
97 sleep 30
98 curl -f https://staging.example.com/health
99
100 # Stage 5: Deploy Production
101 deploy-production:
102 runs-on: ubuntu-latest
103 needs: deploy-staging
104 environment:
105 name: production
106 url: https://example.com
107 steps:
108 - name: Deploy to Production
109 uses: appleboy/ssh-action@master
110 with:
111 host: ${{ secrets.PROD_HOST }}
112 username: deploy
113 key: ${{ secrets.SSH_KEY }}
114 script: |
115 cd /app
116 docker-compose -f docker-compose.green.yml pull
117 docker-compose -f docker-compose.green.yml up -d
118 sleep 30
119 curl -f http://localhost:3001/health && nginx -s reload
120
121 - name: Notify Team
122 uses: 8398a7/action-slack@v3
123 with:
124 status: ${{ job.status }}
125 webhook_url: ${{ secrets.SLACK_WEBHOOK }}This pipeline:- Runs lint in 1 min- Runs tests in 3-5 min- Builds Docker image in 2-3 min- Deploys to staging automatically- Waits for manual approval- Deploys to production with health checks- Notifies team on SlackTotal time: 8-12 minutes from commit to production.Cost: FREE (GitHub Actions gives 2,000 minutes/month free)Setup time: 4-6 hoursResult: Deploy confidently multiple times per day.
We help teams design and ship production-grade software in eLearning, fintech, and AI. Let's talk about your project.
Book a call