Building Production CI/CD Pipelines with GitHub Actions and AWS

C
CodeNex Engineering TeamEngineering Team
September 11, 2025
15 min read
#CI/CD#GitHub Actions#AWS#ECS#Automation

Building Production CI/CD Pipelines with GitHub Actions and AWS

Continuous Integration and Continuous Deployment (CI/CD) is essential for modern software development. This guide shows you how to build production-ready pipelines.

Why CI/CD Matters

Without CI/CD:

  • Deployments take hours instead of minutes
  • Manual steps lead to errors and inconsistencies
  • No automated testing means bugs reach production
  • Rollbacks are difficult when things go wrong

Pipeline Architecture

Our recommended architecture:

┌─────────────┐
│ Git Push    │
└──────┬──────┘
       │
       v
┌─────────────────┐
│ Run Tests       │
│ - Unit          │
│ - Integration   │
│ - Linting       │
└──────┬──────────┘
       │
       v
┌─────────────────┐
│ Build Docker    │
│ Image           │
└──────┬──────────┘
       │
       v
┌─────────────────┐
│ Push to ECR     │
└──────┬──────────┘
       │
       v
┌─────────────────┐
│ Deploy to ECS   │
│ (Blue/Green)    │
└──────┬──────────┘
       │
       v
┌─────────────────┐
│ Run Smoke Tests │
└─────────────────┘

Step 1: Setup GitHub Actions

Create .github/workflows/deploy.yml:

name: Deploy to Production

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

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: myapp
  ECS_SERVICE: myapp-service
  ECS_CLUSTER: production
  CONTAINER_NAME: web

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json

  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Download task definition
        run: |
          aws ecs describe-task-definition \
            --task-definition myapp-task \
            --query taskDefinition > task-definition.json

      - name: Fill in new image ID in task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-definition.json
          container-name: ${{ env.CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.image }}

      - name: Deploy to Amazon ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

      - name: Run smoke tests
        run: |
          npm run test:smoke

Step 2: Environment Variables and Secrets

Store sensitive data in GitHub Secrets:

  1. Go to repository Settings > Secrets and variables > Actions
  2. Add secrets:
    • AWS_ACCESS_KEY_ID
    • AWS_SECRET_ACCESS_KEY
    • DATABASE_URL (for app configuration)

Use environment-specific secrets:

- name: Deploy to Staging
  if: github.ref == 'refs/heads/develop'
  env:
    DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
    API_KEY: ${{ secrets.STAGING_API_KEY }}

Step 3: Multi-Environment Setup

Support dev, staging, and production:

name: Multi-Environment Deploy

on:
  push:
    branches:
      - main
      - develop
      - 'release/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - branch: develop
            environment: development
            cluster: dev-cluster
          - branch: main
            environment: production
            cluster: prod-cluster

    steps:
      - uses: actions/checkout@v3

      - name: Deploy to ${{ matrix.environment }}
        if: github.ref == 'refs/heads/${{ matrix.branch }}'
        run: |
          echo "Deploying to ${{ matrix.environment }}"
          # Deploy logic here

Step 4: Database Migrations

Handle database migrations safely:

- name: Run database migrations
  run: |
    npm run migrate:up
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

- name: Verify migration
  run: |
    npm run migrate:status

Step 5: Rollback Strategy

Implement automatic rollback on failure:

- name: Deploy new version
  id: deploy
  run: |
    aws ecs update-service \
      --cluster production \
      --service myapp \
      --task-definition myapp:$NEW_VERSION

- name: Wait for deployment
  id: wait
  run: |
    aws ecs wait services-stable \
      --cluster production \
      --services myapp
  continue-on-error: true

- name: Rollback on failure
  if: steps.wait.outcome == 'failure'
  run: |
    echo "Deployment failed, rolling back..."
    aws ecs update-service \
      --cluster production \
      --service myapp \
      --task-definition myapp:$PREVIOUS_VERSION

Advanced Patterns

Blue/Green Deployment

- name: Blue/Green Deploy
  run: |
    # Deploy to green environment
    terraform apply -target=module.green_environment

    # Run health checks
    ./scripts/health-check.sh green

    # Switch traffic
    terraform apply -target=module.load_balancer

    # Monitor for 10 minutes
    sleep 600

    # Destroy blue environment
    terraform destroy -target=module.blue_environment

Canary Deployment

Deploy to subset of users first:

- name: Canary Deploy (10%)
  run: |
    aws ecs update-service \
      --cluster production \
      --service myapp-canary \
      --desired-count 1

    sleep 300  # Monitor for 5 minutes

    # Check error rates
    ./scripts/check-metrics.sh

- name: Full Deploy (100%)
  run: |
    aws ecs update-service \
      --cluster production \
      --service myapp \
      --desired-count 10

Monitoring and Notifications

Send Slack notifications:

- name: Notify Slack on Success
  if: success()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "✅ Deployment succeeded",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "Deployment to production succeeded\nCommit: ${{ github.sha }}"
            }
          }
        ]
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

- name: Notify Slack on Failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "❌ Deployment failed",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "Deployment to production failed\nCommit: ${{ github.sha }}\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>"
            }
          }
        ]
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Performance Optimization

Caching Dependencies

- name: Cache node modules
  uses: actions/cache@v3
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

- name: Cache Docker layers
  uses: actions/cache@v3
  with:
    path: /tmp/.buildx-cache
    key: ${{ runner.os }}-buildx-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-buildx-

Parallel Jobs

Run tests in parallel:

test:
  strategy:
    matrix:
      node-version: [16, 18, 20]
      test-suite: [unit, integration, e2e]
  runs-on: ubuntu-latest
  steps:
    - name: Run ${{ matrix.test-suite }} tests on Node ${{ matrix.node-version }}
      run: npm run test:${{ matrix.test-suite }}

Security Best Practices

  1. Use OIDC instead of long-lived credentials:
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v2
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
    aws-region: us-east-1
  1. Scan Docker images for vulnerabilities:
- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
    format: 'sarif'
    output: 'trivy-results.sarif'

- name: Upload Trivy results to GitHub Security
  uses: github/codeql-action/upload-sarif@v2
  with:
    sarif_file: 'trivy-results.sarif'
  1. Sign commits and verify signatures
  2. Use branch protection rules
  3. Require code review before merging

Real-World Metrics

After implementing CI/CD for clients, we typically see:

  • Deployment time: 2 hours → 10 minutes (92% reduction)
  • Deployment frequency: Weekly → Multiple times per day
  • Failed deployments: 15% → 2% (87% reduction)
  • Mean time to recovery: 4 hours → 15 minutes

Conclusion

A well-designed CI/CD pipeline:

  • ✅ Reduces deployment time and risk
  • ✅ Increases deployment frequency
  • ✅ Improves code quality through automation
  • ✅ Enables faster feedback loops
  • ✅ Reduces manual errors

Ready to automate your deployments? Download our CI/CD templates or book a consultation.