Shortcuts
BlogNovember 1, 2024

Mohamed Elbarry
CI/CD Pipeline Setup
We once shipped a broken build because tests and lint weren’t running on the branch we merged. After that I made sure every push to main (and every PR targeting it) runs the same steps: install, lint, test, build. Deploys run only when those pass. Caching dependencies is a big win: runners start clean, so without a cache every run re-downloads npm packages. Use actions/cache with a key that includes a hash of package-lock.json (and optional restore-keys for partial matches) so you get cache hits when deps haven’t changed and much faster jobs. Here’s the pipeline I use and how I extend it. GitHub’s workflow syntax and caching dependencies are the references I use. We used this pattern for Lumin Search and client projects so regressions never reached production. I run CI on push to main and develop and on every PR to main. I pin Node and use the GitHub cache for dependencies so runs stay fast. If any step fails, the workflow fails and we don’t deploy.
Yaml
# .github/workflows/ci.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18, 20]

    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run linting
        run: npm run lint
When we need a container image I build and push on push to main or on version tags. I use the GitHub Actions cache for Docker layers so rebuilds are faster.
Yaml
# .github/workflows/docker.yml
name: Docker Build and Push

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            myapp:latest
            myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
I deploy staging on push to develop and production on version tags. Both use the same pattern: checkout, then run the deploy script on the target host via SSH. I keep secrets in GitHub and never hardcode them.
Yaml
# .github/workflows/deploy-staging.yml
name: Deploy to Staging

on:
  push:
    branches: [develop]

jobs:
  deploy-staging:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Staging
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.STAGING_SSH_KEY }}
          script: |
            cd /var/www/staging
            git pull origin develop
            npm ci
            npm run build
            pm2 restart staging-app
Yaml
# .github/workflows/deploy-production.yml
name: Deploy to Production

on:
  push:
    tags: ['v*']

jobs:
  deploy-production:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Production
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.PRODUCTION_SSH_KEY }}
          script: |
            cd /var/www/production
            git pull origin main
            npm ci --production
            npm run build
            pm2 restart production-app

      - name: Notify Deployment
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          channel: '#deployments'
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}
I run migrations in a separate workflow that triggers on push to main (or after a deploy), with the DB URL in secrets. I run a quick verification step after so we notice failed migrations immediately.
Yaml
# .github/workflows/migrate-database.yml
name: Database Migration

on:
  push:
    branches: [main]

jobs:
  migrate:
    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 database migrations
        run: npm run migrate
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      - name: Verify migration
        run: npm run verify-migration
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
I run a vulnerability scan on the repo and upload results so we can track and fix issues. I also run npm audit so dependency issues show up in the same run.
Yaml
# .github/workflows/security.yml
name: Security Scan

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  security:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          scan-ref: '.'
          format: 'sarif'
          output: 'trivy-results.sarif'

      - name: Upload Trivy scan results
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'

      - name: Run npm audit
        run: npm audit --audit-level moderate
I send Slack (or similar) notifications on workflow completion so the team sees success or failure without opening GitHub. I use workflow_run so one workflow can react to another.
Yaml
# .github/workflows/notify.yml
name: Notify Team

on:
  workflow_run:
    workflows: ['CI/CD Pipeline']
    types: [completed]

jobs:
  notify:
    runs-on: ubuntu-latest
    if: always()

    steps:
      - name: Notify Success
        if: ${{ github.event.workflow_run.conclusion == 'success' }}
        uses: 8398a7/action-slack@v3
        with:
          status: success
          channel: '#deployments'
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}
          text: 'Deployment successful'

      - name: Notify Failure
        if: ${{ github.event.workflow_run.conclusion == 'failure' }}
        uses: 8398a7/action-slack@v3
        with:
          status: failure
          channel: '#deployments'
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}
          text: 'Deployment failed. Check the logs.'
Run lint and test on every relevant push and PR; block merge or deploy on failure. Use secrets for credentials and never log them. Keep workflows as fast as possible (cache deps, use matrix only when needed). Deploy staging from a branch and production from tags or main so releases are explicit. One thing to try: add a single workflow that runs on PR and fails if npm run lint or npm test fails, then make it required for merging. GitHub Actions docs are the place to go for syntax and limits. Hope that helps. I'm currently looking for new challenges in the AI and Full Stack space. If you're building something interesting, let's chat.
Share this post: