Hands-on project building a complete CI/CD pipeline with GitHub Actions. Set up testing, building, deployment, and monitoring automation.
Building a CI/CD pipeline from scratch is one of the most valuable skills a developer can demonstrate. It shows employers you understand the full software delivery lifecycle — not just writing code, but ensuring it is tested, built, and deployed reliably every single time. This project takes you through building a production-grade pipeline for a real application.
By the end, you will have a pipeline that automatically tests code on every pull request, builds and deploys on merge to main, runs scheduled health checks, and notifies the team when something goes wrong.
Project: Full-Stack Blog Application Pipeline
We will build the CI/CD pipeline for a blog application with a React frontend and Node.js API backend:
# Project structure
blog-app/
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # Test on every PR
│ │ ├── deploy.yml # Deploy on merge to main
│ │ ├── schedule.yml # Nightly health checks
│ │ └── release.yml # Version tagging and changelog
│ ├── CODEOWNERS
│ └── dependabot.yml
├── frontend/ # React application
│ ├── package.json
│ ├── src/
│ └── tests/
├── backend/ # Node.js API
│ ├── package.json
│ ├── src/
│ └── tests/
├── e2e/ # End-to-end tests
│ └── tests/
├── docker-compose.yml
└── README.md
Phase 1: Continuous Integration Pipeline
The CI pipeline runs on every pull request to catch issues before code reaches main:
# .github/workflows/ci.yml
name: CI Pipeline
on:
pull_request:
branches: [main, develop]
# Limit permissions for security
permissions:
contents: read
pull-requests: write
checks: write
jobs:
# Job 1: Fast checks that fail quickly
lint:
name: Lint & Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run format:check
# Job 2: Run tests across Node versions
test:
name: Test (Node ${{ matrix.node }})
runs-on: ubuntu-latest
needs: lint # Only run if lint passes
strategy:
matrix:
node: [18, 20, 22]
fail-fast: false # Run all versions even if one fails
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
if: matrix.node == 20 # Only upload from primary version
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
# Job 3: Build verification
build:
name: Build Check
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
# Job 4: Security scanning
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm audit --audit-level=high
- uses: github/codeql-action/init@v3
with:
languages: javascript
- uses: github/codeql-action/analyze@v3
Key design decisions:
- Lint runs first (fast fail — no point running 10-minute tests if formatting is wrong)
- Matrix testing ensures compatibility across Node versions
- fail-fast: false lets all matrix jobs complete for full picture
- Artifact uploads preserve build outputs and coverage reports
- Security scanning catches vulnerabilities in dependencies and code
Phase 2: Deployment Pipeline
Triggers only when code is merged to main (already passed CI):
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
permissions:
contents: read
deployments: write
jobs:
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.myblog.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci && npm run build
- name: Deploy to staging
env:
DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
run: |
echo "Deploying to staging environment..."
npx vercel --token=$DEPLOY_KEY --env=staging
e2e-tests:
name: E2E Tests on Staging
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Run Playwright tests against staging
env:
BASE_URL: https://staging.myblog.com
run: npx playwright test
deploy-production:
name: Deploy to Production
needs: e2e-tests
runs-on: ubuntu-latest
environment:
name: production
url: https://myblog.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci && npm run build
- name: Deploy to production
env:
DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}
run: |
echo "Deploying to production..."
npx vercel --token=$DEPLOY_KEY --prod
The pipeline follows a promotion model: staging first, run E2E tests against staging, then production only if tests pass.
Phase 3: Scheduled Monitoring
# .github/workflows/schedule.yml
name: Nightly Health Checks
on:
schedule:
- cron: '0 2 * * *' # 2 AM UTC daily
workflow_dispatch: # Allow manual trigger
jobs:
health-check:
name: Production Health Check
runs-on: ubuntu-latest
steps:
- name: Check homepage responds
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://myblog.com)
if [ "$STATUS" != "200" ]; then
echo "❌ Homepage returned $STATUS"
exit 1
fi
echo "✅ Homepage OK ($STATUS)"
- name: Check API health endpoint
run: |
RESPONSE=$(curl -s https://myblog.com/api/health)
echo "API Response: $RESPONSE"
if ! echo "$RESPONSE" | grep -q '"status":"healthy"'; then
echo "❌ API health check failed"
exit 1
fi
- name: Check response time
run: |
TIME=$(curl -s -o /dev/null -w "%{time_total}" https://myblog.com)
echo "Response time: ${TIME}s"
if (( $(echo "$TIME > 3.0" | bc -l) )); then
echo "⚠️ Response time exceeds 3s threshold"
exit 1
fi
dependency-check:
name: Check for Vulnerabilities
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm audit --audit-level=critical
Phase 4: Release Automation
# .github/workflows/release.yml
name: Create Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for changelog
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
CHANGES=$(git log --pretty=format:"- %s" HEAD)
else
CHANGES=$(git log --pretty=format:"- %s" $PREV_TAG..HEAD)
fi
echo "changes<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
body: |
## Changes
${{ steps.changelog.outputs.changes }}
generate_release_notes: true
Learning Outcomes
After completing this project, you will understand:
- Writing workflow YAML files with proper triggers and conditions
- Using matrix builds for cross-version and cross-platform testing
- Implementing deployment pipelines with environment promotion (staging → production)
- Setting up scheduled monitoring and health checks
- Working with secrets and environment variables securely
- Using artifacts to pass data between jobs
- Configuring GitHub Environments with approval gates
- Automating releases and changelogs
Common Mistakes
Mistake 1: Not caching dependencies. Adding cache: npm to setup-node saves 30-60 seconds per job. Multiply by dozens of jobs per day and it adds up.
Mistake 2: Running all tests for every change. Use path filters to skip running backend tests when only frontend files changed.
Mistake 3: Storing secrets in workflow files. Always use GitHub Secrets (Settings → Secrets and variables → Actions) and reference with ${{ secrets.NAME }}.
Mistake 4: Not using permissions to limit the GITHUB_TOKEN scope. Default permissions are too broad — always restrict to minimum needed.
Interview Questions
Q1: How did you structure your CI/CD pipeline?
Lint first (fast fail in 30 seconds), then tests in parallel across Node versions, build verification, and security scanning. On merge to main: deploy to staging, run E2E tests against staging, then promote to production. Each phase gates the next.
Q2: How do you handle failing deployments?
Health checks run after deployment. If staging E2E tests fail, production deployment is blocked automatically. For production issues, the scheduled health check alerts us, and we can revert by reverting the merge commit and redeploying.
Q3: What did you learn about GitHub Actions optimization?
Caching dependencies saves significant time, matrix builds parallelize testing, path filters prevent unnecessary runs, and artifact retention should be limited to avoid storage costs. Using needs for job dependencies ensures logical ordering.
Q4: How do you manage secrets across environments?
Environment-scoped secrets in GitHub (different API keys for staging vs production), minimal permissions per secret, regular rotation schedules, and never logging or echoing secret values in workflow output.
Q5: What would you add to improve this pipeline?
Preview deployments for PRs (deploy each PR to a unique URL), performance benchmarking with regression detection, automated dependency updates with Dependabot, notification integrations (Slack on failure), and canary deployments for gradual rollout.