CI/CD Pipeline with GitHub Actions: Automate Deployments

Why CI/CD Is Non-Negotiable

Manual deployments are the enemy of fast, reliable software delivery. Every time a developer SSHs into a server and runs commands by hand, they introduce risk: missed steps, wrong environment variables, untested code reaching production. Continuous Integration and Continuous Deployment (CI/CD) eliminates this entirely by automating the path from code commit to live deployment.

GitHub Actions is now the most popular CI/CD platform for open-source and commercial projects alike. It’s built into GitHub, requires zero external infrastructure, and offers a generous free tier. In this guide, we’ll build a production-grade pipeline for a Node.js application from scratch.

Understanding GitHub Actions Concepts

Before writing YAML, let’s clarify the terminology. A workflow is a configurable automated process defined in a YAML file inside .github/workflows/. A workflow contains one or more jobs, and each job contains a sequence of steps. Jobs run in parallel by default but can depend on each other. Steps can run shell commands or use pre-built actions from the GitHub Marketplace.

ADVERTISEMENT

Workflows are triggered by events: push, pull_request, schedule, manual dispatch, and many others. You can filter triggers by branch, path, or tag.

The Complete Workflow File

Create .github/workflows/ci-cd.yml in your repository. This workflow handles linting, testing, building, and deploying:

name: CI/CD Pipeline

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

env:
  NODE_VERSION: '20'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint-and-test:
    name: Lint & Test
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - 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 ESLint
        run: npx eslint . --max-warnings 0

      - name: Run unit tests
        run: npm test -- --coverage
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  build:
    name: Build Docker Image
    needs: lint-and-test
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Login to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=semver,pattern={{version}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-staging:
    name: Deploy to Staging
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/develop'
    environment: staging

    steps:
      - name: Deploy to staging server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:develop
            docker compose -f docker-compose.staging.yml up -d
            docker system prune -f

  deploy-production:
    name: Deploy to Production
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
            docker compose -f docker-compose.prod.yml up -d --no-deps app
            docker system prune -f

      - name: Verify deployment
        run: |
          sleep 30
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://myapp.com/health)
          if [ "$STATUS" != "200" ]; then
            echo "Health check failed with status $STATUS"
            exit 1
          fi

Managing Secrets Securely

Never hardcode credentials in workflow files. GitHub provides encrypted secrets at the repository and environment level. Navigate to Settings > Secrets and variables > Actions in your repository to add them.

# Required secrets for this pipeline:
# STAGING_HOST       - IP or hostname of staging server
# PROD_HOST          - IP or hostname of production server
# DEPLOY_USER        - SSH username for deployment
# SSH_PRIVATE_KEY    - Private key for SSH authentication
# GITHUB_TOKEN       - Automatically provided by GitHub

For environment-specific secrets, use GitHub Environments. You can add required reviewers so production deployments need manual approval before proceeding.

Optimizing Pipeline Speed with Caching

Slow pipelines kill developer productivity. The actions/setup-node@v4 action with cache: 'npm' automatically caches the npm global cache. For Docker builds, we use GitHub Actions cache (type=gha) to cache image layers. Here are additional caching strategies:

# Cache Cypress binary for E2E tests
- name: Cache Cypress
  uses: actions/cache@v4
  with:
    path: ~/.cache/Cypress
    key: cypress-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

# Cache Playwright browsers
- name: Cache Playwright
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

Adding Notifications and Status Badges

Keep your team informed about pipeline status. Add a Slack notification step at the end of your deploy job:

      - name: Notify Slack
        if: always()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: repo,message,commit,author,workflow
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Add a status badge to your README:

![CI/CD](https://github.com/YOUR_USER/YOUR_REPO/actions/workflows/ci-cd.yml/badge.svg)

Branch Protection Rules

CI/CD pipelines only work if you enforce them. Go to Settings > Branches > Branch protection rules for your main branch and enable: Require status checks to pass before merging, selecting the lint-and-test job. Also enable Require pull request reviews before merging. This ensures no untested code reaches your main branch.

Conclusion

You now have a production-grade CI/CD pipeline that lints, tests, builds Docker images, and deploys to staging and production with zero manual intervention. The key principles are: automate everything repeatable, gate deployments behind tests, keep secrets out of code, and cache aggressively. From here, consider adding E2E tests with Playwright, database migration steps, and canary deployments. The workflow file is your infrastructure — treat it with the same care as application code.

ADVERTISEMENT

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.