What We're Building

Cloud Engineering Architecture

A real, production-grade GitHub Actions pipeline with 5 stages: Build โ†’ Unit Test โ†’ Docker Build + Push โ†’ Deploy to Staging โ†’ Manual Gate โ†’ Deploy to Production. The same pattern I run across 12 microservices at my current company. No tutorial shortcuts.

Prerequisites

  • GitHub account + a repository with a Node.js/Python/any app
  • Docker Hub or AWS ECR account (for image push)
  • An EC2 instance or server to deploy to (or just Staging = EC2, Prod = manual)

Stage 1: The Workflow File Structure

Create .github/workflows/ci-cd.yml in your repository root.

name: CI/CD Pipeline

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

env:
  REGISTRY: docker.io
  IMAGE_NAME: yourusername/yourapp
  EC2_HOST: ${{ secrets.EC2_HOST }}

jobs:
  build-and-test:
    name: Build & Test
    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 unit tests
        run: npm test
      
      - name: Run linting
        run: npm run lint

Stage 2: Docker Build + Security Scan

  docker-build-push:
    name: Docker Build & Push
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4
      
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
      
      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
      
      - name: Run Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.IMAGE_NAME }}:latest
          format: 'sarif'
          output: 'trivy-results.sarif'
          exit-code: '1'
          severity: 'CRITICAL,HIGH'

Stage 3: Deploy to Staging

  deploy-staging:
    name: Deploy to Staging
    needs: docker-build-push
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Deploy to staging via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ubuntu
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ env.IMAGE_NAME }}:latest
            docker stop app-staging || true
            docker rm app-staging || true
            docker run -d \
              --name app-staging \
              --env-file /home/ubuntu/.env.staging \
              -p 3000:3000 \
              --restart unless-stopped \
              ${{ env.IMAGE_NAME }}:latest
            docker system prune -f

Stage 4: Manual Gate + Production Deploy

  deploy-production:
    name: Deploy to Production
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production  # This requires manual approval in GitHub Environments
    steps:
      - name: Deploy to production
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ubuntu
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ env.IMAGE_NAME }}:latest
            docker stop app-prod || true && docker rm app-prod || true
            docker run -d \
              --name app-prod \
              --env-file /home/ubuntu/.env.prod \
              -p 80:3000 \
              --restart always \
              ${{ env.IMAGE_NAME }}:latest

Setting Up GitHub Environments for Manual Approval

Go to your repo โ†’ Settings โ†’ Environments โ†’ Create "production" โ†’ Add required reviewers. When a deploy hits this stage, GitHub will pause and wait for a human to approve. This is the manual gate that prevents accidental production deployments.

Secrets to Configure

  • DOCKERHUB_USERNAME and DOCKERHUB_TOKEN
  • SSH_PRIVATE_KEY โ€” the private key to SSH into your servers
  • STAGING_HOST and PROD_HOST โ€” IP addresses of your servers
๐Ÿ’ก Pro tip from production: Always add a smoke test job after each deployment โ€” hit a /health endpoint and fail the pipeline if it returns anything other than 200. Silent failed deploys are the worst kind.